Compare commits
46
Commits
3581ad7c8e
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b14613decd | ||
|
|
3563549e86 | ||
|
|
450d65116f | ||
|
|
ea28f8aa2e |
@@ -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
|
||||||
@@ -81,3 +81,30 @@ S3_BUCKET=ekklesia-development
|
|||||||
# EKKLESIA_SMTP_PASSWORD=
|
# EKKLESIA_SMTP_PASSWORD=
|
||||||
# [email protected]
|
# [email protected]
|
||||||
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||||
|
#
|
||||||
|
# EKKLESIA_SMTP_HELO_NAME is the name Ekklesia announces itself as in the SMTP EHLO command.
|
||||||
|
#
|
||||||
|
# Leave it unset unless a relay refuses the connection. An SMTP client has to identify itself, and
|
||||||
|
# with nothing configured Nodemailer derives that from the machine hostname — substituting the
|
||||||
|
# address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal
|
||||||
|
# state of a container or an unnamed VM. Some relays close the connection on that greeting with a
|
||||||
|
# 421 before any message is offered, which looks exactly like the relay being unreachable.
|
||||||
|
#
|
||||||
|
# Set it to a fully qualified domain name your deployment owns — typically the sending domain.
|
||||||
|
# No scheme, no port, no brackets, and at least one dot; anything else is refused at startup.
|
||||||
|
# EKKLESIA_SMTP_HELO_NAME=example.org
|
||||||
|
|
||||||
|
# Where an emailed verification or password-reset link sends a browser (ADR 0038).
|
||||||
|
#
|
||||||
|
# Absent means the Dashboard is served from EKKLESIA_IDENTITY_BASE_URL, which is the ordinary
|
||||||
|
# same-origin production shape. Set it only when a deployment separates the two, for example an API
|
||||||
|
# at https://api.example.org and the Dashboard at https://dash.example.org. It must also appear in
|
||||||
|
# EKKLESIA_IDENTITY_TRUSTED_ORIGINS, or the identity system refuses the emailed link — Ekklesia
|
||||||
|
# checks that at startup rather than letting every recipient discover it.
|
||||||
|
#
|
||||||
|
# This is the authenticated Dashboard host, never the public church website.
|
||||||
|
# EKKLESIA_DASHBOARD_ORIGIN=https://dash.example.org
|
||||||
|
#
|
||||||
|
# Verification and password recovery need a working relay above. The initial platform-owner
|
||||||
|
# bootstrap (pnpm identity:create-initial-owner) deliberately does not: it creates that one account
|
||||||
|
# already verified, so a fresh server can be signed in to before any mail exists.
|
||||||
|
|||||||
@@ -103,3 +103,69 @@ jobs:
|
|||||||
|
|
||||||
- name: Exercise transactional migrations against PostgreSQL
|
- name: Exercise transactional migrations against PostgreSQL
|
||||||
run: pnpm test:migrations:integration
|
run: pnpm test:migrations:integration
|
||||||
|
|
||||||
|
image-build:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
- name: Build the production image without modules
|
||||||
|
run: >-
|
||||||
|
docker build
|
||||||
|
--file deploy/Dockerfile
|
||||||
|
--build-arg REVISION="$GITHUB_SHA"
|
||||||
|
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Remove the image from the runner
|
||||||
|
if: always()
|
||||||
|
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
|
||||||
|
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ monorepo:
|
|||||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
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
|
`@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
|
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
|
`@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.
|
`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.
|
private source paths.
|
||||||
- Use `workspace:` ranges for internal package dependencies.
|
- 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
|
- 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
|
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||||
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
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
|
`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
|
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
|
||||||
bundler configuration, and styling is React inline style objects only for now. Both roles use the
|
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
|
||||||
same UI SDK context with no presentation-mode flag.
|
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
|
- 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
|
`@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
|
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
|
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||||
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||||
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
`@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
|
- 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
|
`/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
|
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
|
- 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
|
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
|
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ order never grants elevation — but is now **disabled by default, and cannot be
|
|||||||
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
|
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
|
||||||
[API](docs/development/api.md) and
|
[API](docs/development/api.md) and
|
||||||
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
||||||
Email verification, password recovery, and invitation/operator enrollment still do not exist.
|
Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
|
||||||
|
enrollment still do not.
|
||||||
Public registration remains disabled by default and prohibited in production — and a fresh
|
Public registration remains disabled by default and prohibited in production — and a fresh
|
||||||
production installation now has a supported way to get its first administrator anyway: Phase 7B
|
production installation now has a supported way to get its first administrator anyway: Phase 7B
|
||||||
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
|
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
|
||||||
@@ -288,7 +289,8 @@ artifacts cannot drift.
|
|||||||
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
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);
|
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
|
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,
|
`.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.
|
and installing a module or starting the API never triggers one.
|
||||||
|
|
||||||
@@ -487,7 +489,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
|
|||||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||||
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||||
manifest is version 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
|
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||||
no key management, so a `secret` key's value is supplied by the deployment
|
no key management, so a `secret` key's value is supplied by the deployment
|
||||||
@@ -719,12 +722,31 @@ ambiguous, and retrying it would send someone several copies of the same reset e
|
|||||||
single bounded attempt and deliberately not an exactly-once promise. There is **no
|
single bounded attempt and deliberately not an exactly-once promise. There is **no
|
||||||
`POST /api/v1/send-email`** and no route that touches the provider.
|
`POST /api/v1/send-email`** and no route that touches the provider.
|
||||||
|
|
||||||
Email remains **optional**: an installation with no SMTP configured is a complete deployment, since
|
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
|
||||||
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without
|
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
|
||||||
sending anything**, while stating plainly that a successful connection does not establish
|
while stating plainly that a successful connection does not establish deliverability. See
|
||||||
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's
|
[Outbound email](docs/development/email.md).
|
||||||
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
|
|
||||||
the seam they will use. See [Outbound email](docs/development/email.md).
|
Phase 7C pass 2 completes the human identity lifecycle over that transport
|
||||||
|
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
|
||||||
|
verified email address is a precondition for holding a session**: the identity system refuses an
|
||||||
|
unverified sign-in after checking the password and before creating one, so a correct password on an
|
||||||
|
unverified address carries exactly the authority of no password at all — which is why no
|
||||||
|
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
|
||||||
|
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
|
||||||
|
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
|
||||||
|
can steer where a recipient lands, and following a verification link deliberately mints **no
|
||||||
|
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
|
||||||
|
leaves managed devices untouched. Password recovery answers identically for an address with an
|
||||||
|
account, without one, already verified, or undeliverable — including when SMTP is broken, which is
|
||||||
|
why the delivery adapter never throws.
|
||||||
|
|
||||||
|
**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates
|
||||||
|
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
|
||||||
|
can be signed in to before any relay exists. Normal production use of verification and recovery does
|
||||||
|
require configured outbound email. **Public signup remains disabled** — verification is not a licence
|
||||||
|
to self-register — and invitations and user administration are still not built. See
|
||||||
|
[Identity](docs/development/identity.md).
|
||||||
|
|
||||||
Ekklesia publishes the display bundle at
|
Ekklesia publishes the display bundle at
|
||||||
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
||||||
@@ -772,6 +794,74 @@ unproven launch-blockers rather than existing guarantees. Read
|
|||||||
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
||||||
module or exposing the API.
|
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
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
@@ -869,7 +959,7 @@ Object storage
|
|||||||
|
|
||||||
Outbound email
|
Outbound email
|
||||||
○ Outbound email not configured
|
○ Outbound email not configured
|
||||||
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
|
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
|
||||||
|
|
||||||
Result: READY WITH WARNINGS
|
Result: READY WITH WARNINGS
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -28,11 +28,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@ekklesia/module-ui-host": "workspace:*",
|
"@ekklesia/module-ui-host": "workspace:*",
|
||||||
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
|
"@types/smtp-server": "3.5.13",
|
||||||
"better-auth": "1.6.23",
|
"better-auth": "1.6.23",
|
||||||
|
"happy-dom": "20.0.11",
|
||||||
"pg": "8.22.0",
|
"pg": "8.22.0",
|
||||||
|
"smtp-server": "3.19.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ import type { Context, MiddlewareHandler } from "hono";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createDeviceRoutes } from "./device-routes.js";
|
import { createDeviceRoutes } from "./device-routes.js";
|
||||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
|
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||||
@@ -137,6 +140,20 @@ export interface CreateApiAppOptions {
|
|||||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||||
* ordering, module startup, or any authorization decision. */
|
* ordering, module startup, or any authorization decision. */
|
||||||
readonly dashboard: DashboardStore;
|
readonly dashboard: DashboardStore;
|
||||||
|
/**
|
||||||
|
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||||
|
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||||
|
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
|
||||||
|
* `./dashboard-host-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly dashboardHostAssets: DashboardHostAssets;
|
||||||
|
/**
|
||||||
|
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
|
||||||
|
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||||
|
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
|
||||||
|
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly health: ApiHealthOptions;
|
||||||
readonly runtime: EkklesiaIdentityRuntime;
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||||
@@ -341,6 +358,16 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
|
||||||
|
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
|
||||||
|
// own entirely. See `./health-routes.js`.
|
||||||
|
app.route("/", createHealthRoutes(options.health));
|
||||||
|
|
||||||
|
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||||
|
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||||
|
// `./dashboard-host-routes.js`.
|
||||||
|
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||||
|
|
||||||
app.route(
|
app.route(
|
||||||
"/",
|
"/",
|
||||||
createGrantsRoutes({
|
createGrantsRoutes({
|
||||||
|
|||||||
+187
-17
@@ -1,6 +1,10 @@
|
|||||||
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
|
import {
|
||||||
import type { Context } from "hono";
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
import { bodyLimit } from "hono/body-limit";
|
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
|
* 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
|
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
||||||
* abuse.
|
* abuse.
|
||||||
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
* - `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
|
* constant a module's own **buffered** route declaration is validated against at registration) is
|
||||||
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
|
* the ceiling for every body the platform materializes before a handler runs. It has not moved.
|
||||||
* nothing in this platform ever declares, or enforces, a larger single request body than this.
|
* - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
|
||||||
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
|
* explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
|
||||||
* a future dedicated storage design, not to widening this ceiling.
|
* `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 AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
||||||
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 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
|
/** 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
|
* 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);
|
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
|
* A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
|
||||||
* `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
|
* A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
|
||||||
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
|
* the byte counting below bounds the request either way, so refusing here could only ever turn a
|
||||||
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
|
* legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
|
||||||
export function platformBodyLimit(maxSize: number) {
|
* parser rejects genuinely malformed framing before this code sees the request at all.
|
||||||
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
|
*/
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
+20
-2
@@ -79,8 +79,10 @@ export interface ApiRuntimeConfig {
|
|||||||
* The validated host outbound-email provider, or `undefined` when this installation has none
|
* The validated host outbound-email provider, or `undefined` when this installation has none
|
||||||
* (ADR 0037).
|
* (ADR 0037).
|
||||||
*
|
*
|
||||||
* `undefined` is a complete, valid deployment: nothing in the platform sends email yet, and a
|
* `undefined` is a valid deployment: the platform starts, every existing feature works, and the
|
||||||
* future feature that needs to discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing.
|
* initial platform owner — created already verified by an operator command — can still sign in
|
||||||
|
* (ADR 0038). What it cannot do is verify a new account or deliver a password reset; each attempt
|
||||||
|
* reports a sanitized `MAIL_NOT_CONFIGURED` out of band rather than changing any HTTP response.
|
||||||
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
|
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
|
||||||
* API response ever sees the host or the password.
|
* API response ever sees the host or the password.
|
||||||
*/
|
*/
|
||||||
@@ -198,3 +200,19 @@ export function createApiRuntimeConfig(
|
|||||||
storage,
|
storage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact browser origins the API trusts: CORS on `/api/auth/*` and `/api/v1/*`, and every
|
||||||
|
* mutation route's origin check (ADR 0045).
|
||||||
|
*
|
||||||
|
* The configured trusted origins plus the identity base URL's own origin, which is where the API
|
||||||
|
* lives. Better Auth already trusts its base URL implicitly; this makes the API's own checks agree,
|
||||||
|
* so a page the API itself serves (the display host, and the Dashboard in the same-origin topology)
|
||||||
|
* passes without an operator listing the API's origin. Order is preserved and duplicates removed;
|
||||||
|
* every entry is already a canonical origin.
|
||||||
|
*/
|
||||||
|
export function apiTrustedOrigins(
|
||||||
|
identity: Pick<IdentityConfig, "baseUrl" | "trustedOrigins">,
|
||||||
|
): readonly string[] {
|
||||||
|
return Object.freeze([...new Set([...identity.trustedOrigins, identity.baseUrl])]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||||
|
*
|
||||||
|
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||||
|
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||||
|
* package is never exposed as a static directory, and the source map beside the script is never
|
||||||
|
* read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value
|
||||||
|
* every response can reuse.
|
||||||
|
*/
|
||||||
|
export interface DashboardHostAssets {
|
||||||
|
readonly html: string;
|
||||||
|
readonly htmlEtag: string;
|
||||||
|
readonly script: string;
|
||||||
|
readonly scriptEtag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
|
* location, and machine path are intentionally not retained or surfaced. */
|
||||||
|
export class DashboardHostAssetError extends Error {
|
||||||
|
readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("The Dashboard host browser assets are unavailable.");
|
||||||
|
this.name = "DashboardHostAssetError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strongEtag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures one immutable, internally consistent Dashboard asset snapshot.
|
||||||
|
*
|
||||||
|
* Each strong validator is derived from the exact text in the same snapshot, so bytes from one build
|
||||||
|
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||||
|
* valid page that can never render anything.
|
||||||
|
*/
|
||||||
|
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
html,
|
||||||
|
htmlEtag: strongEtag(html),
|
||||||
|
script,
|
||||||
|
scriptEtag: strongEtag(script),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell
|
||||||
|
* reads the filled-in value at run time; empty means the page's own origin.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
|
||||||
|
|
||||||
|
function isCanonicalHttpOrigin(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (url.protocol === "https:" || url.protocol === "http:") && url.origin === value;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the API's own origin into the Dashboard page, once, before the page's `ETag` is computed.
|
||||||
|
*
|
||||||
|
* The origin is the identity base URL's, which is where `/api` lives in either topology: the page's
|
||||||
|
* own origin when the Dashboard and the API share one, the API hostname when they are split. The
|
||||||
|
* built page must carry exactly one empty placeholder; anything else is a shell build this API does
|
||||||
|
* not understand, and startup fails rather than serving a page that might send credentials somewhere
|
||||||
|
* unintended. The value is escaped for an attribute even though a canonical origin cannot contain
|
||||||
|
* any character that needs it.
|
||||||
|
*/
|
||||||
|
export function renderDashboardPage(html: string, apiOrigin: string): string {
|
||||||
|
const parts = html.split(DASHBOARD_API_ORIGIN_PLACEHOLDER);
|
||||||
|
if (parts.length !== 2 || !isCanonicalHttpOrigin(apiOrigin)) {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
const escaped = apiOrigin
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
|
||||||
|
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
|
||||||
|
* `/` with a 404 or a page that cannot find its API. */
|
||||||
|
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
|
||||||
|
try {
|
||||||
|
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
|
||||||
|
// rejected sibling read is left running after this function has already failed.
|
||||||
|
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||||
|
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||||
|
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
|
||||||
|
} catch {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
|
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||||
|
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||||
|
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||||
|
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||||
|
* `/api/v1/modules/<module-id>`.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||||
|
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||||
|
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
|
||||||
|
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
|
||||||
|
*/
|
||||||
|
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
|
||||||
|
* entry never keeps an older set.
|
||||||
|
*
|
||||||
|
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
|
||||||
|
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
|
||||||
|
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
|
||||||
|
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
|
||||||
|
*/
|
||||||
|
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
|
||||||
|
* package location, or asset content. */
|
||||||
|
export class DashboardHostConfigurationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("The Dashboard host configuration is inconsistent.");
|
||||||
|
this.name = "DashboardHostConfigurationError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies and validates injected assets before registering any route, as the display host does, so
|
||||||
|
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||||
|
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||||
|
try {
|
||||||
|
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||||
|
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedAsset(
|
||||||
|
ifNoneMatch: string | undefined,
|
||||||
|
body: string,
|
||||||
|
etag: string,
|
||||||
|
contentType: string,
|
||||||
|
extraHeaders: Readonly<Record<string, string>>,
|
||||||
|
): Response {
|
||||||
|
if (etagMatches(ifNoneMatch, etag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
...extraHeaders,
|
||||||
|
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
|
||||||
|
"Content-Type": contentType,
|
||||||
|
ETag: etag,
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||||
|
* default same-origin shape.
|
||||||
|
*
|
||||||
|
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||||
|
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||||
|
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||||
|
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||||
|
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||||
|
*/
|
||||||
|
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||||
|
const snapshot = snapshotAssets(assets);
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||||
|
fixedAsset(
|
||||||
|
c.req.header("if-none-match"),
|
||||||
|
snapshot.html,
|
||||||
|
snapshot.htmlEtag,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||||
|
fixedAsset(
|
||||||
|
c.req.header("if-none-match"),
|
||||||
|
snapshot.script,
|
||||||
|
snapshot.scriptEtag,
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -26,11 +26,31 @@ export interface ApiDatabaseHandle {
|
|||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||||
|
/**
|
||||||
|
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
||||||
|
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
||||||
|
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
||||||
|
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
||||||
|
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
||||||
|
* the pool discards that connection instead of returning it, so a transaction left open by a
|
||||||
|
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
||||||
|
* caller may surface it.
|
||||||
|
*/
|
||||||
|
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
readonly close: () => Promise<void>;
|
readonly close: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pingStatement(statementTimeoutMs: number): string {
|
||||||
|
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
||||||
|
// parameters. Only a positive safe integer can ever reach the SQL text.
|
||||||
|
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
||||||
|
throw new RangeError("The ping statement timeout must be a positive integer.");
|
||||||
|
}
|
||||||
|
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
@@ -58,5 +78,8 @@ export function createApiDatabase(
|
|||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
moduleConfiguration: createModuleConfigurationView(pool),
|
moduleConfiguration: createModuleConfigurationView(pool),
|
||||||
moduleLifecycle: createModuleLifecycleView(pool),
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
|
ping: async (statementTimeoutMs: number) => {
|
||||||
|
await pool.query(pingStatement(statementTimeoutMs));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
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
|
* 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
|
* 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 html: string;
|
||||||
readonly script: string;
|
readonly script: string;
|
||||||
readonly scriptEtag: 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
|
/** 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
|
* 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.
|
* failed host build rather than a valid page that can never mount anything.
|
||||||
*/
|
*/
|
||||||
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
|
export function createDisplayHostAssets(
|
||||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
html: string,
|
||||||
|
script: string,
|
||||||
|
serviceWorker: string,
|
||||||
|
): DisplayHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||||
throw new DisplayHostAssetError();
|
throw new DisplayHostAssetError();
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
html,
|
html,
|
||||||
script,
|
script,
|
||||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
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. */
|
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
|||||||
// rejected sibling promise is left running after this function has already failed.
|
// rejected sibling promise is left running after this function has already failed.
|
||||||
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||||
const script = await readFile(new URL(displayHostAssetUrls.script), "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 {
|
} catch {
|
||||||
throw new DisplayHostAssetError();
|
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,
|
getAuthorizedPrincipal,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
export {
|
||||||
|
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 { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-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";
|
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||||
|
|
||||||
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
/** 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. */
|
* `/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 const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||||
|
/**
|
||||||
export function moduleDisplayPageUrl(moduleId: string): string {
|
* Where the display's offline service worker is served (ADR 0043).
|
||||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
*
|
||||||
}
|
* 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
|
||||||
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
|
||||||
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
* 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 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
|
* 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 id: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
readonly display: { readonly url: 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 api: { readonly baseUrl: string };
|
||||||
readonly realtime: { readonly baseUrl: string };
|
readonly realtime: { readonly baseUrl: string };
|
||||||
readonly permissions: readonly 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);
|
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
|
|
||||||
function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
|
||||||
if (headerValue === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (headerValue.trim() === "*") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return headerValue.split(",").some((candidate) => {
|
|
||||||
const value = candidate.trim();
|
|
||||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Copies and validates injected assets before registering any route. Production's loader already
|
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||||
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||||
* bytes with a stale validator. */
|
* bytes with a stale validator. */
|
||||||
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||||
try {
|
try {
|
||||||
const snapshot = createDisplayHostAssets(input.html, input.script);
|
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||||
if (snapshot.scriptEtag !== input.scriptEtag) {
|
if (
|
||||||
|
snapshot.scriptEtag !== input.scriptEtag ||
|
||||||
|
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||||
|
) {
|
||||||
throw new ModuleDisplayConfigurationError();
|
throw new ModuleDisplayConfigurationError();
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -132,8 +141,10 @@ async function describeDisplay(
|
|||||||
viewer,
|
viewer,
|
||||||
module: {
|
module: {
|
||||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||||
|
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||||
id: display.moduleId,
|
id: display.moduleId,
|
||||||
|
page: { url: moduleDisplayPageUrl(display.moduleId) },
|
||||||
permissions: await grantedModulePermissionIds(
|
permissions: await grantedModulePermissionIds(
|
||||||
options.evaluator,
|
options.evaluator,
|
||||||
principal,
|
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) {
|
for (const display of displays) {
|
||||||
mountDisplay(app, { ...options, assets }, display);
|
mountDisplay(app, { ...options, assets }, display);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,9 +235,12 @@ export function mountModuleHttpRoutes(
|
|||||||
|
|
||||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||||
// all) is enforced before permission evaluation ever runs, so an oversized request never
|
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
|
||||||
// reaches authorization work, let alone the handler or the module database.
|
// never reaches authorization work, let alone the handler or the module database. In
|
||||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
|
// `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 path = moduleRoutePath(route.moduleId, route.path);
|
||||||
const finalHandler = async (c: Context): Promise<Response> => {
|
const finalHandler = async (c: Context): Promise<Response> => {
|
||||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
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 { Hono } from "hono";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import {
|
import {
|
||||||
@@ -9,8 +13,13 @@ import {
|
|||||||
requirePermission,
|
requirePermission,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
|
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.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
|
* 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
|
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
||||||
* routing can change without touching a single line of host or module code. */
|
* routing can change without touching a single line of host or module code. */
|
||||||
readonly ui: { readonly url: string };
|
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
|
/** 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
|
* 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
|
* `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.
|
* nor trusts anything in this array.
|
||||||
*/
|
*/
|
||||||
readonly permissions: readonly string[];
|
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 {
|
export interface HostModuleUiInventory {
|
||||||
readonly modules: readonly HostModuleUiDescriptor[];
|
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 {
|
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||||
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||||
@@ -116,9 +173,13 @@ async function describeModule(
|
|||||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
principal: EkklesiaPrincipal,
|
principal: EkklesiaPrincipal,
|
||||||
published: PublishableModuleUi,
|
published: PublishableModuleUi,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
): Promise<HostModuleUiDescriptor> {
|
): Promise<HostModuleUiDescriptor> {
|
||||||
|
const display = registry.resolveDisplay(published.moduleId, published.version);
|
||||||
return {
|
return {
|
||||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||||
|
contributions: describeModuleContributions(registry, published.moduleId),
|
||||||
|
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
|
||||||
id: published.moduleId,
|
id: published.moduleId,
|
||||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||||
@@ -153,7 +214,7 @@ export async function describeMountableModules(
|
|||||||
): Promise<readonly MountableModuleDescription[]> {
|
): Promise<readonly MountableModuleDescription[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
registry.list().map(async (published) => ({
|
registry.list().map(async (published) => ({
|
||||||
descriptor: await describeModule(evaluator, principal, published),
|
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||||
name: published.name,
|
name: published.name,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
|||||||
return `${moduleUiArtifactUrl(identity)}/display`;
|
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
|
* 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
|
* 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
|
* ## The request never touches the filesystem
|
||||||
*
|
*
|
||||||
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
|
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||||
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
|
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||||
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
* `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
|
* 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
|
* 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
|
* 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 {
|
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||||
const routes = new 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) => {
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||||
const published = options.registry.resolveDisplay(
|
const published = options.registry.resolveDisplay(
|
||||||
c.req.param("moduleId"),
|
c.req.param("moduleId"),
|
||||||
|
|||||||
+57
-18
@@ -13,7 +13,12 @@ import {
|
|||||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import {
|
||||||
|
createIdentityNotifications,
|
||||||
|
createIdentityRuntime,
|
||||||
|
type IdentityNotificationReporter,
|
||||||
|
type IdentityRateLimitOverride,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||||
import {
|
import {
|
||||||
createModuleConfigurationStore,
|
createModuleConfigurationStore,
|
||||||
@@ -39,10 +44,12 @@ import {
|
|||||||
createClientAddressResolver,
|
createClientAddressResolver,
|
||||||
wrapFetchWithClientAddress,
|
wrapFetchWithClientAddress,
|
||||||
} from "./client-address.js";
|
} from "./client-address.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
|
||||||
|
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||||
import { withDevicePrincipals } from "./device-principal.js";
|
import { withDevicePrincipals } from "./device-principal.js";
|
||||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
|
||||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||||
|
|
||||||
const connectionTimeoutMs = 10_000;
|
const connectionTimeoutMs = 10_000;
|
||||||
@@ -138,6 +145,15 @@ export interface StartApiOptions {
|
|||||||
/** Test-only escape hatch: never set by the executable entry point. See
|
/** Test-only escape hatch: never set by the executable entry point. See
|
||||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
|
/**
|
||||||
|
* Where a verification or reset message that could not be delivered is reported.
|
||||||
|
*
|
||||||
|
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
|
||||||
|
* seam because the *only* externally observable difference between "the relay is down" and "there
|
||||||
|
* is no such account" must be this report — every HTTP response is deliberately identical — so a
|
||||||
|
* test needs somewhere to observe the operational failure the user is never shown.
|
||||||
|
*/
|
||||||
|
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
||||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
||||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||||
readonly modulesDirectoryOverride?: string;
|
readonly modulesDirectoryOverride?: string;
|
||||||
@@ -430,6 +446,14 @@ export async function startApi(
|
|||||||
transport: config.databaseTransport,
|
transport: config.databaseTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Both host providers are constructed from validated configuration **before** anything that uses
|
||||||
|
// them, and neither touches the network while being built: an S3 client opens no connection and a
|
||||||
|
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
|
||||||
|
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
|
||||||
|
// `undefined` for both, which is a complete, valid deployment.
|
||||||
|
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
||||||
|
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
||||||
|
|
||||||
let services: ModuleRuntimeServices;
|
let services: ModuleRuntimeServices;
|
||||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||||
@@ -439,11 +463,22 @@ export async function startApi(
|
|||||||
runtime = createIdentityRuntime({
|
runtime = createIdentityRuntime({
|
||||||
config: config.identity,
|
config: config.identity,
|
||||||
db: databaseHandle.identity,
|
db: databaseHandle.identity,
|
||||||
|
// Identity's two outbound messages, composed here — the one place that knows both the
|
||||||
|
// validated identity configuration and the host's mail provider. Core's identity code learns
|
||||||
|
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
|
||||||
|
notifications: createIdentityNotifications({
|
||||||
|
authBaseUrl: config.identity.baseUrl,
|
||||||
|
dashboardOrigin: config.identity.dashboardOrigin,
|
||||||
|
mail: mailProvider,
|
||||||
|
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
|
||||||
|
}),
|
||||||
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
||||||
});
|
});
|
||||||
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
||||||
deviceStore = createDeviceStore(databaseHandle.devices);
|
deviceStore = createDeviceStore(databaseHandle.devices);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -460,21 +495,6 @@ export async function startApi(
|
|||||||
// start, before a single module entrypoint is imported. A failure here fails startup rather than
|
// start, before a single module entrypoint is imported. A failure here fails startup rather than
|
||||||
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
||||||
// module an administrator disabled is worse than not starting at all.
|
// module an administrator disabled is worse than not starting at all.
|
||||||
// The host's object-storage provider, constructed from validated configuration before any module
|
|
||||||
// runs — and **without touching the network**. Building the client opens no connection, so a
|
|
||||||
// storage outage can never stop the platform from booting; a bucket is ensured lazily on first
|
|
||||||
// use instead (ADR 0030). An installation with no storage configured gets `undefined` here, which
|
|
||||||
// is a complete, valid deployment.
|
|
||||||
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
|
||||||
|
|
||||||
// The host's outbound mail provider, constructed from validated configuration and — like the
|
|
||||||
// storage client above — **without touching the network**. Building a Nodemailer transport opens
|
|
||||||
// no socket and sends nothing, so an unreachable relay can never stop the platform from booting;
|
|
||||||
// the first connection happens when something actually sends (ADR 0037). Nothing sends yet: this
|
|
||||||
// exists so Pass 2 can hand it to Better Auth's verification and reset hooks. An installation with
|
|
||||||
// no SMTP configured gets `undefined`, which is a complete, valid deployment.
|
|
||||||
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
|
||||||
|
|
||||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||||
const moduleConfigurationStore = createModuleConfigurationStore(
|
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||||
databaseHandle.moduleConfiguration,
|
databaseHandle.moduleConfiguration,
|
||||||
@@ -524,9 +544,15 @@ export async function startApi(
|
|||||||
// starts and application composition succeeds.
|
// starts and application composition succeeds.
|
||||||
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||||
|
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
|
||||||
try {
|
try {
|
||||||
displayHostAssets =
|
displayHostAssets =
|
||||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||||
|
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||||
|
// so a missing shell build fails here, before any module code is imported.
|
||||||
|
// The page learns its API origin from the API that serves it, so one build serves either
|
||||||
|
// topology (ADR 0045).
|
||||||
|
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
await safeCloseMail(mailProvider);
|
await safeCloseMail(mailProvider);
|
||||||
@@ -558,6 +584,10 @@ export async function startApi(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
|
||||||
|
// `503` while the instance still finishes in-flight requests.
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
let app: Hono;
|
let app: Hono;
|
||||||
try {
|
try {
|
||||||
app = createApiApp({
|
app = createApiApp({
|
||||||
@@ -602,6 +632,13 @@ export async function startApi(
|
|||||||
},
|
},
|
||||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
|
dashboardHostAssets,
|
||||||
|
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||||
|
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||||
|
health: {
|
||||||
|
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
|
||||||
|
isModuleStartupComplete: () => !shuttingDown,
|
||||||
|
},
|
||||||
devices: {
|
devices: {
|
||||||
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
||||||
// base URL rather than from any request. Production is https by configuration policy, so a
|
// base URL rather than from any request. Production is https by configuration policy, so a
|
||||||
@@ -613,7 +650,8 @@ export async function startApi(
|
|||||||
// Principal resolution, extended with managed devices. A human session still wins, and this
|
// Principal resolution, extended with managed devices. A human session still wins, and this
|
||||||
// is the only place a device credential ever becomes a principal.
|
// is the only place a device credential ever becomes a principal.
|
||||||
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
|
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
// The configured origins plus the API's own (ADR 0045).
|
||||||
|
trustedOrigins: apiTrustedOrigins(config.identity),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeStopModuleRuntime(moduleRuntime);
|
await safeStopModuleRuntime(moduleRuntime);
|
||||||
@@ -659,6 +697,7 @@ export async function startApi(
|
|||||||
let shutdownPromise: Promise<void> | undefined;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = (): Promise<void> => {
|
const shutdown = (): Promise<void> => {
|
||||||
if (!shutdownPromise) {
|
if (!shutdownPromise) {
|
||||||
|
shuttingDown = true;
|
||||||
shutdownPromise = (async () => {
|
shutdownPromise = (async () => {
|
||||||
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
||||||
// completes: an in-flight module command may still commit and publish its resulting state.
|
// completes: an in-flight module command may still commit and publish its resulting state.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -26,7 +26,19 @@ import {
|
|||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type QueryResultRow } from "pg";
|
||||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||||
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { createApiDatabase } from "../src/database.js";
|
||||||
|
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
|
import {
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
import {
|
import {
|
||||||
MODULE_UI_CACHE_CONTROL,
|
MODULE_UI_CACHE_CONTROL,
|
||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
@@ -224,6 +236,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
@@ -329,7 +342,7 @@ async function createGatedRouteModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -373,7 +386,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -431,6 +444,39 @@ async function createFixtureModule(
|
|||||||
async handler(request, context) {
|
async handler(request, context) {
|
||||||
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
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 });
|
||||||
|
},
|
||||||
});`
|
});`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
@@ -478,7 +524,7 @@ async function createUiModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
@@ -522,6 +568,32 @@ function apiOrigin(running: RunningApi): string {
|
|||||||
return `http://127.0.0.1:${address.port}`;
|
return `http://127.0.0.1:${address.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs
|
||||||
|
* (ADR 0038).
|
||||||
|
*
|
||||||
|
* Deliberately not a weakening of the policy: it is the test-harness equivalent of an operator with
|
||||||
|
* database credentials, used only so scenarios that are about something else are not obliged to run
|
||||||
|
* an SMTP server. That the policy itself is enforced — that an unverified account genuinely cannot
|
||||||
|
* sign in — is asserted separately, both in this file and in `email-verification.integration.ts`.
|
||||||
|
*/
|
||||||
|
async function markEmailVerified(origin: string, email: string): Promise<void> {
|
||||||
|
const url = databaseUrlByOrigin.get(origin);
|
||||||
|
if (!url) {
|
||||||
|
throw harnessError("No database is registered for that API origin.");
|
||||||
|
}
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface StartConfiguredApiOptions {
|
interface StartConfiguredApiOptions {
|
||||||
readonly clientAddress?: ClientAddressConfigInput;
|
readonly clientAddress?: ClientAddressConfigInput;
|
||||||
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
|
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
|
||||||
@@ -537,6 +609,16 @@ interface StartConfiguredApiOptions {
|
|||||||
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
|
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The database each running API in this suite is attached to, keyed by its bound origin.
|
||||||
|
*
|
||||||
|
* Needed because Phase 7C Pass 2 made a verified address a precondition for holding a session
|
||||||
|
* (ADR 0038), so `signUp` below has to mark the account verified before these scenarios — which are
|
||||||
|
* about cookies, CSRF, principals, sessions, and authorization, not about verification — can sign
|
||||||
|
* in. Verification itself has its own dedicated end-to-end suite.
|
||||||
|
*/
|
||||||
|
const databaseUrlByOrigin = new Map<string, string>();
|
||||||
|
|
||||||
async function startConfiguredApi(
|
async function startConfiguredApi(
|
||||||
url: string,
|
url: string,
|
||||||
options: StartConfiguredApiOptions = {},
|
options: StartConfiguredApiOptions = {},
|
||||||
@@ -571,7 +653,7 @@ async function startConfiguredApi(
|
|||||||
},
|
},
|
||||||
{ environment },
|
{ environment },
|
||||||
);
|
);
|
||||||
return startApi(config, {
|
const running = await startApi(config, {
|
||||||
...("rateLimitOverride" in options
|
...("rateLimitOverride" in options
|
||||||
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
||||||
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
||||||
@@ -585,6 +667,8 @@ async function startConfiguredApi(
|
|||||||
? {}
|
? {}
|
||||||
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
|
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
|
||||||
});
|
});
|
||||||
|
databaseUrlByOrigin.set(apiOrigin(running), url);
|
||||||
|
return running;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startMigratedApi(
|
async function startMigratedApi(
|
||||||
@@ -763,6 +847,12 @@ async function signUp(
|
|||||||
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// A verified address is a precondition for holding a session (ADR 0038). Sign-up itself always
|
||||||
|
// writes `emailVerified: false`, so these scenarios — none of which are about verification —
|
||||||
|
// complete that step out of band rather than standing up an SMTP server apiece.
|
||||||
|
await markEmailVerified(origin, email);
|
||||||
|
}
|
||||||
return { body: (await response.clone().json()) as SignUpResult["body"], response };
|
return { body: (await response.clone().json()) as SignUpResult["body"], response };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2640,6 +2730,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
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 () => {
|
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
||||||
const admin = await openAdministrativeClient(database.url);
|
const admin = await openAdministrativeClient(database.url);
|
||||||
try {
|
try {
|
||||||
@@ -2832,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", () => {
|
describe.sequential("live API — public signup is disabled by default", () => {
|
||||||
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
||||||
const database = await createDisposableDatabase("signup_disabled");
|
const database = await createDisposableDatabase("signup_disabled");
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import {
|
|||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
type ModuleDatabase,
|
||||||
type ModuleRuntimeServices,
|
type ModuleRuntimeServices,
|
||||||
|
moduleIdSchema,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
parsePermissionId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationEvaluator,
|
type AuthorizationEvaluator,
|
||||||
type AuthorizationStore,
|
type AuthorizationStore,
|
||||||
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
CORE_PERMISSION_REGISTRY,
|
CORE_PERMISSION_REGISTRY,
|
||||||
|
createPermissionRegistry,
|
||||||
type PermissionRegistry,
|
type PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
@@ -16,6 +21,7 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
type ModuleUiPublicationRegistry,
|
type ModuleUiPublicationRegistry,
|
||||||
|
StagedModuleRealtimeRegistry,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +31,8 @@ import {
|
|||||||
} from "../src/app.js";
|
} from "../src/app.js";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
@@ -104,6 +112,8 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -121,6 +131,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -355,6 +367,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -378,6 +392,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -403,6 +419,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -430,6 +448,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -457,6 +477,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -481,6 +503,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -507,6 +531,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -530,6 +556,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -555,6 +583,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -587,6 +617,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -620,6 +652,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -658,6 +692,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -678,6 +714,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -700,6 +738,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -721,6 +761,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -740,6 +782,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -766,6 +810,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -795,6 +841,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -827,6 +875,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
@@ -850,6 +900,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -864,3 +916,101 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||||
|
const moduleId = moduleIdSchema.parse("announcements");
|
||||||
|
const permission = parsePermissionId("announcements.slideshow.view");
|
||||||
|
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||||
|
|
||||||
|
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
|
||||||
|
* it: the same composition a Dashboard on another origin reaches. */
|
||||||
|
function realtimeApp() {
|
||||||
|
const staged = new StagedModuleRealtimeRegistry();
|
||||||
|
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||||
|
registration.realtime.channel({ channel: "state", permission });
|
||||||
|
registration.commit();
|
||||||
|
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
|
||||||
|
const moduleRealtime = staged.publish();
|
||||||
|
const app = createApiAppWithModules({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
moduleRealtime,
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({
|
||||||
|
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
...CORE_PERMISSION_DECLARATIONS,
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: permission,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
runtime: fakeRuntime({
|
||||||
|
resolvePrincipal: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
createAuthenticatedPrincipal(
|
||||||
|
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, moduleRealtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
|
||||||
|
const { app, moduleRealtime } = realtimeApp();
|
||||||
|
|
||||||
|
const response = await app.request(endpoint, {
|
||||||
|
headers: { cookie: "session=1", origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||||
|
expect(response.headers.get("vary")).toContain("Origin");
|
||||||
|
await response.body?.cancel();
|
||||||
|
moduleRealtime.closeSubscriptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
|
||||||
|
const { app, moduleRealtime } = realtimeApp();
|
||||||
|
|
||||||
|
const response = await app.request(endpoint, {
|
||||||
|
headers: { cookie: "session=1", origin: untrustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
await response.body?.cancel();
|
||||||
|
moduleRealtime.closeSubscriptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
|
||||||
|
const app = createAuthWiringApp(fakeRuntime());
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal", {
|
||||||
|
headers: {
|
||||||
|
"access-control-request-headers": "content-type, x-module-custom",
|
||||||
|
"access-control-request-method": "PATCH",
|
||||||
|
origin: trustedOrigin,
|
||||||
|
},
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
|
||||||
|
"content-type",
|
||||||
|
);
|
||||||
|
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
|
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
|
||||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||||
|
|
||||||
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
|
|||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
identity: {
|
identity: {
|
||||||
baseUrl: "https://identity.example.test",
|
baseUrl: "https://identity.example.test",
|
||||||
|
// Defaults to the identity base URL: the Dashboard and the API share one origin unless a
|
||||||
|
// deployment separates them (ADR 0038).
|
||||||
|
dashboardOrigin: "https://identity.example.test",
|
||||||
publicSignupEnabled: false,
|
publicSignupEnabled: false,
|
||||||
secret: validSecret,
|
secret: validSecret,
|
||||||
trustedOrigins: ["https://identity.example.test"],
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
@@ -334,3 +337,24 @@ describe("API runtime configuration", () => {
|
|||||||
expect(publicText).not.toContain("db.internal.example");
|
expect(publicText).not.toContain("db.internal.example");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the API's trusted browser origins (ADR 0045)", () => {
|
||||||
|
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
|
||||||
|
const origins = apiTrustedOrigins({
|
||||||
|
baseUrl: "https://api.example.test",
|
||||||
|
trustedOrigins: ["https://dash.example.test"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
|
||||||
|
expect(Object.isFrozen(origins)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
|
||||||
|
expect(
|
||||||
|
apiTrustedOrigins({
|
||||||
|
baseUrl: "https://ekklesia.example.test",
|
||||||
|
trustedOrigins: ["https://ekklesia.example.test"],
|
||||||
|
}),
|
||||||
|
).toEqual(["https://ekklesia.example.test"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||||
|
dashboardHostAssetUrls: Object.freeze({
|
||||||
|
html: "file:///surface/browser/index.html",
|
||||||
|
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||||
|
DashboardHostAssetError,
|
||||||
|
loadDashboardHostAssets,
|
||||||
|
renderDashboardPage,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
const API_ORIGIN = "https://api.example.test";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard host asset loading", () => {
|
||||||
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
|
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
|
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||||
|
const script = "export const dashboardHost = true;\n";
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||||
|
|
||||||
|
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||||
|
|
||||||
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
|
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||||
|
]);
|
||||||
|
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||||
|
expect(assets).toEqual({
|
||||||
|
html: rendered,
|
||||||
|
htmlEtag: sha256Etag(rendered),
|
||||||
|
script,
|
||||||
|
scriptEtag: sha256Etag(script),
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||||
|
readFileMock.mockRejectedValueOnce(
|
||||||
|
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||||
|
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||||
|
expect(publicText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the script is missing even though the page was read", async () => {
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||||
|
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||||
|
|
||||||
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
|
DashboardHostAssetError,
|
||||||
|
);
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||||
|
for (const [html, script] of [
|
||||||
|
["", "export {};"],
|
||||||
|
["<html></html>", " \n"],
|
||||||
|
] as const) {
|
||||||
|
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
|
||||||
|
for (const html of [
|
||||||
|
"<!doctype html>",
|
||||||
|
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
|
||||||
|
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||||
|
]) {
|
||||||
|
readFileMock.mockReset();
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||||
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
|
DashboardHostAssetError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard page API origin (ADR 0045)", () => {
|
||||||
|
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
|
||||||
|
|
||||||
|
it("writes the API's origin into the one placeholder, in either topology", () => {
|
||||||
|
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
|
||||||
|
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
|
||||||
|
);
|
||||||
|
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
|
||||||
|
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://api.example.test/",
|
||||||
|
"https://api.example.test/api",
|
||||||
|
'https://api.example.test"><script>',
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"",
|
||||||
|
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
|
||||||
|
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
|
||||||
|
// while answering a request — the hallmark of a static directory — fails these tests.
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
DashboardHostConfigurationError,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
|
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("dashboard.page.read");
|
||||||
|
|
||||||
|
const PAGE_HTML =
|
||||||
|
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
|
||||||
|
const SCRIPT = "export const dashboard = 'built shell';\n";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||||
|
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
options: {
|
||||||
|
readonly assets?: DashboardHostAssets;
|
||||||
|
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||||
|
health: readyHealth(),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPageSecurityHeaders(response: Response): void {
|
||||||
|
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
||||||
|
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||||
|
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
|
||||||
|
expect(response.status, label).toBe(404);
|
||||||
|
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET / (the Dashboard page)", () => {
|
||||||
|
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
// The one CSP directive is the only one: the full policy is a separate decision.
|
||||||
|
expect(response.headers.get("content-security-policy")).not.toContain(";");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": ifNoneMatch },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status, ifNoneMatch).toBe(304);
|
||||||
|
expect(await response.text(), ifNoneMatch).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the full page again when the browser holds a different build's validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": sha256Etag("an older build") },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
|
||||||
|
const { app, evaluator, runtime } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
expect(response.headers.get("set-cookie")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||||
|
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(SCRIPT);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(304);
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept the page's validator as the script's", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": PAGE_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard host boundaries", () => {
|
||||||
|
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/index.html",
|
||||||
|
"/app.js",
|
||||||
|
"/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/",
|
||||||
|
"/assets/ekklesia-dashboard",
|
||||||
|
"/assets/ekklesia-dashboard/app.js/",
|
||||||
|
"/assets/ekklesia-dashboard/index.html",
|
||||||
|
"/assets/",
|
||||||
|
"/assets/ekklesia-dashboard/APP.JS",
|
||||||
|
"/dashboard",
|
||||||
|
"/login",
|
||||||
|
]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path), path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
|
||||||
|
"/%2e%2e/%2e%2e/etc/passwd",
|
||||||
|
"/../../etc/passwd",
|
||||||
|
"//assets/ekklesia-dashboard/app.js",
|
||||||
|
"/assets//ekklesia-dashboard/app.js",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%00.map",
|
||||||
|
]) {
|
||||||
|
const response = await app.request(path);
|
||||||
|
expect(await response.text(), path).not.toContain(SCRIPT);
|
||||||
|
expect(response.status, path).toBe(404);
|
||||||
|
}
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches the filesystem while answering, even across every served path", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
await app.request("/assets/ekklesia-dashboard/app.js.map");
|
||||||
|
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(await head.text()).toBe("");
|
||||||
|
|
||||||
|
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const { app } = buildApp({
|
||||||
|
moduleRoutes: [
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "dashboard",
|
||||||
|
path: "/",
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
|
||||||
|
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
|
||||||
|
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
|
||||||
|
// No display is published here, so the display host's script is absent rather than answered
|
||||||
|
// by the Dashboard's.
|
||||||
|
await expectSanitizedNotFound(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH),
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
);
|
||||||
|
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
|
||||||
|
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||||
|
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||||
|
|
||||||
|
for (const assets of [
|
||||||
|
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||||
|
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||||
|
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||||
|
]) {
|
||||||
|
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,8 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import {
|
import {
|
||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
moduleLifecycleSnapshotFor,
|
moduleLifecycleSnapshotFor,
|
||||||
@@ -75,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -139,6 +141,8 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
@@ -269,7 +273,9 @@ describe("Dashboard navigation — reading", () => {
|
|||||||
|
|
||||||
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
||||||
expect(livestream?.module).toEqual({
|
expect(livestream?.module).toEqual({
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/livestream" },
|
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||||
|
display: null,
|
||||||
id: "livestream",
|
id: "livestream",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
|
|||||||
displayHostAssetUrls: Object.freeze({
|
displayHostAssetUrls: Object.freeze({
|
||||||
html: "file:///surface/browser/display/index.html",
|
html: "file:///surface/browser/display/index.html",
|
||||||
script: "file:///surface/browser/display/app.js",
|
script: "file:///surface/browser/display/app.js",
|
||||||
|
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -20,21 +21,28 @@ import {
|
|||||||
describe("display host asset loading", () => {
|
describe("display host asset loading", () => {
|
||||||
beforeEach(() => readFileMock.mockReset());
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||||
const script = "export const displayHost = true;\n";
|
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();
|
const assets = await loadDisplayHostAssets();
|
||||||
|
|
||||||
expect(readFileMock.mock.calls).toEqual([
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||||
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||||
]);
|
]);
|
||||||
expect(assets).toEqual({
|
expect(assets).toEqual({
|
||||||
html,
|
html,
|
||||||
script,
|
script,
|
||||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
});
|
});
|
||||||
expect(Object.isFrozen(assets)).toBe(true);
|
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", () => {
|
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||||
for (const [html, script] of [
|
for (const [html, script, serviceWorker] of [
|
||||||
["", "export {};"],
|
["", "export {};", "export {};"],
|
||||||
["<html></html>", " \n"],
|
["<html></html>", " \n", "export {};"],
|
||||||
|
["<html></html>", "export {};", " \n"],
|
||||||
] as const) {
|
] as const) {
|
||||||
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError);
|
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
|
||||||
|
DisplayHostAssetError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import { createHmac, randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
type IdentityNotificationFailure,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { SMTPServer } from "smtp-server";
|
||||||
|
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email verification and password recovery, end to end (ADR 0038).
|
||||||
|
*
|
||||||
|
* Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better
|
||||||
|
* Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The
|
||||||
|
* verification and reset links are **extracted from the delivered message** and then followed, which
|
||||||
|
* is the only way to prove the whole path rather than the pieces — a fake mail function would prove
|
||||||
|
* that a callback was invoked and nothing about whether the resulting link works.
|
||||||
|
*
|
||||||
|
* `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the
|
||||||
|
* internet and no real email is ever sent.
|
||||||
|
*
|
||||||
|
* Public signup is disabled in every scenario, matching production. Accounts that are not the
|
||||||
|
* bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses,
|
||||||
|
* which is deliberately not reachable over HTTP.
|
||||||
|
*/
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
const dashboardOrigin = "http://dashboard.localhost:5173";
|
||||||
|
const integrationSecret = "integration-test-secret-0123456789abcdefgh";
|
||||||
|
const initialPassword = "correct horse battery staple";
|
||||||
|
const replacementPassword = "an entirely different passphrase";
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const url = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof url !== "string" || url.trim() === "") {
|
||||||
|
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(name: string): string {
|
||||||
|
const url = new URL(baseDatabaseUrl());
|
||||||
|
url.pathname = `/${name}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<T extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<T>(text, [...values]);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
|
||||||
|
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModules,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModules, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freshInstallation(label: string): Promise<string> {
|
||||||
|
databaseCounter += 1;
|
||||||
|
const name =
|
||||||
|
`ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||||
|
const admin = await openAdministrativeClient();
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `create database "${name}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
const url = databaseUrl(name);
|
||||||
|
await applyCoreMigrations(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A delivered message, as the SMTP server actually received it. */
|
||||||
|
interface DeliveredMessage {
|
||||||
|
readonly to: readonly string[];
|
||||||
|
readonly data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailHarness {
|
||||||
|
readonly port: number;
|
||||||
|
readonly delivered: DeliveredMessage[];
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openResources: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const close of openResources.splice(0).reverse()) {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a free port before the API is configured.
|
||||||
|
*
|
||||||
|
* The identity base URL is what the emailed link is built from, so it has to be known *before*
|
||||||
|
* `startApi` binds. Without this the test could only assert the link's shape and would have to
|
||||||
|
* rewrite its host before following it — which is exactly the part worth proving. Reserving and
|
||||||
|
* releasing a port leaves a theoretical race, but nothing else on the loopback interface is
|
||||||
|
* competing for ephemeral ports inside this suite.
|
||||||
|
*/
|
||||||
|
async function reservePort(): Promise<number> {
|
||||||
|
const { createServer } = await import("node:net");
|
||||||
|
const server = createServer();
|
||||||
|
const port = await new Promise<number>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMailServer(): Promise<MailHarness> {
|
||||||
|
const delivered: DeliveredMessage[] = [];
|
||||||
|
const server = new SMTPServer({
|
||||||
|
authOptional: true,
|
||||||
|
disabledCommands: ["STARTTLS"],
|
||||||
|
onData(stream, session, callback) {
|
||||||
|
let data = "";
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
data += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
delivered.push({
|
||||||
|
data,
|
||||||
|
to: session.envelope.rcptTo.map((recipient) => recipient.address),
|
||||||
|
});
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
const address = server.server.address() as AddressInfo;
|
||||||
|
const harness: MailHarness = {
|
||||||
|
close: async () =>
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
}),
|
||||||
|
delivered,
|
||||||
|
port: address.port,
|
||||||
|
};
|
||||||
|
openResources.push(harness.close);
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly url: string;
|
||||||
|
readonly api: RunningApi;
|
||||||
|
readonly origin: string;
|
||||||
|
readonly mail: MailHarness | undefined;
|
||||||
|
readonly notificationFailures: IdentityNotificationFailure[];
|
||||||
|
readonly createIdentity: (input: {
|
||||||
|
readonly email: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly name: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A migrated installation with the API running, optionally with a working SMTP relay.
|
||||||
|
*
|
||||||
|
* `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the
|
||||||
|
* development environment; production configuration refuses both, which `identity-config` and
|
||||||
|
* `mail-contract` assert separately.
|
||||||
|
*/
|
||||||
|
async function installation(
|
||||||
|
label: string,
|
||||||
|
options: { readonly mail?: boolean } = {},
|
||||||
|
): Promise<Installation> {
|
||||||
|
const url = await freshInstallation(label);
|
||||||
|
const mail = options.mail === false ? undefined : await startMailServer();
|
||||||
|
const notificationFailures: IdentityNotificationFailure[] = [];
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
openResources.push(async () => {
|
||||||
|
await identity.close().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiPort = await reservePort();
|
||||||
|
const identityBaseUrl = `http://127.0.0.1:${apiPort}`;
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
publicSignupEnabled: false,
|
||||||
|
secret: integrationSecret,
|
||||||
|
trustedOrigins: [dashboardOrigin],
|
||||||
|
},
|
||||||
|
...(mail
|
||||||
|
? {
|
||||||
|
mail: {
|
||||||
|
fromAddress: "[email protected]",
|
||||||
|
fromName: "Ekklesia",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: String(mail.port),
|
||||||
|
security: "plaintext",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
port: apiPort,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
const api = await startApi(config, {
|
||||||
|
rateLimitOverride: { max: 1000, window: 1 },
|
||||||
|
reportNotificationFailure: (failure) => notificationFailures.push(failure),
|
||||||
|
});
|
||||||
|
openResources.push(async () => {
|
||||||
|
await api.shutdown().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = api.server.address();
|
||||||
|
if (!address || typeof address !== "object" || address.port !== apiPort) {
|
||||||
|
throw new IntegrationHarnessError("The API integration server did not bind the reserved port.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
mail,
|
||||||
|
notificationFailures,
|
||||||
|
origin: identityBaseUrl,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(
|
||||||
|
origin: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
headers: Readonly<Record<string, string>> = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
return await fetch(`${origin}${path}`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||||
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCookiePair(response: Response): string | undefined {
|
||||||
|
const setCookie = response.headers.get("set-cookie");
|
||||||
|
return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(
|
||||||
|
origin: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> {
|
||||||
|
const response = await post(origin, "/api/auth/sign-in/email", { email, password });
|
||||||
|
return { cookiePair: firstCookiePair(response), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function principalKind(origin: string, cookiePair: string | undefined): Promise<string> {
|
||||||
|
const response = await fetch(`${origin}/api/v1/principal`, {
|
||||||
|
headers: cookiePair ? { cookie: cookiePair } : {},
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as { readonly kind?: unknown };
|
||||||
|
return String(body.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a test reads a token, and only ever out of a message this suite's own SMTP server
|
||||||
|
* received. Nothing in the platform exposes one.
|
||||||
|
*
|
||||||
|
* Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by
|
||||||
|
* the encoder, and reassembling it here is what makes the extracted link the one a mail client would
|
||||||
|
* actually resolve.
|
||||||
|
*/
|
||||||
|
function extractLink(message: DeliveredMessage, contains: string): URL {
|
||||||
|
const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", "");
|
||||||
|
const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) =>
|
||||||
|
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||||
|
);
|
||||||
|
const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains));
|
||||||
|
if (!match) {
|
||||||
|
throw new IntegrationHarnessError(`No ${contains} link was delivered.`);
|
||||||
|
}
|
||||||
|
return new URL(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMessage(mail: MailHarness): DeliveredMessage {
|
||||||
|
const message = mail.delivered.at(-1);
|
||||||
|
if (!message) {
|
||||||
|
throw new IntegrationHarnessError("No message was delivered.");
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||||
|
client,
|
||||||
|
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||||
|
[email.toLowerCase()],
|
||||||
|
);
|
||||||
|
return rows[0]?.email_verified;
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account that has completed the real verification round trip.
|
||||||
|
*
|
||||||
|
* Recovery is only meaningful for a verified account, and building one through the actual flow —
|
||||||
|
* rather than by writing `email_verified` directly — keeps every recovery test standing on a
|
||||||
|
* verification path that genuinely works.
|
||||||
|
*/
|
||||||
|
async function verifiedAccountFor(install: Installation, email: string): Promise<void> {
|
||||||
|
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||||
|
await signIn(install.origin, email, initialPassword);
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||||
|
await fetch(link, { redirect: "manual" });
|
||||||
|
(install.mail as MailHarness).delivered.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
|
if (!admin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const name of trackedDatabases) {
|
||||||
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forges a verification token that is structurally valid but already expired.
|
||||||
|
*
|
||||||
|
* Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned
|
||||||
|
* release) — not a database row — so an expired one can be produced here from the same secret this
|
||||||
|
* suite configured, with no library internals and no waiting an hour. This is the only place any
|
||||||
|
* test constructs a token rather than reading one out of a delivered message.
|
||||||
|
*/
|
||||||
|
function forgeExpiredVerificationToken(email: string): string {
|
||||||
|
const encode = (value: object): string =>
|
||||||
|
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||||
|
const issued = Math.floor(Date.now() / 1000) - 7200;
|
||||||
|
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
exp: issued + 3600,
|
||||||
|
iat: issued,
|
||||||
|
})}`;
|
||||||
|
const signature = createHmac("sha256", integrationSecret)
|
||||||
|
.update(signingInput)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${signingInput}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("email verification, end to end", () => {
|
||||||
|
it("takes a new human account from unverified to signed in through a real emailed link", async () => {
|
||||||
|
const install = await installation("flow");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||||
|
|
||||||
|
// 1. Unverified. A correct password does not produce a session — the identity system refuses
|
||||||
|
// before creating one, so there is no half-authenticated state anywhere (ADR 0038).
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||||
|
const refused = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(refused.response.status).toBe(403);
|
||||||
|
expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||||
|
expect(refused.cookiePair).toBeUndefined();
|
||||||
|
expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous");
|
||||||
|
|
||||||
|
// 2. That attempt delivered a verification message through the real SMTP provider.
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
expect(mail.delivered).toHaveLength(1);
|
||||||
|
expect(lastMessage(mail).to).toEqual([email]);
|
||||||
|
expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address");
|
||||||
|
|
||||||
|
// 3. Follow the link exactly as it was delivered. Nothing rewrites it.
|
||||||
|
const link = extractLink(lastMessage(mail), "/verify-email");
|
||||||
|
expect(link.origin).toBe(install.origin);
|
||||||
|
const verified = await fetch(link, { redirect: "manual" });
|
||||||
|
|
||||||
|
// 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the
|
||||||
|
// API origin, and never to anything a request supplied.
|
||||||
|
expect(verified.status).toBe(302);
|
||||||
|
const landing = new URL(verified.headers.get("location") ?? "");
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email");
|
||||||
|
expect(landing.searchParams.get("error")).toBeNull();
|
||||||
|
// No session was minted by following an emailed link.
|
||||||
|
expect(verified.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||||
|
|
||||||
|
// 5. The same credentials now work, and the principal is a fully authenticated human.
|
||||||
|
const accepted = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(accepted.response.status).toBe(200);
|
||||||
|
expect(accepted.cookiePair).toBeDefined();
|
||||||
|
expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed token and an expired token, each with its own reason", async () => {
|
||||||
|
const install = await installation("bad-token");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Expired", password: initialPassword });
|
||||||
|
const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`);
|
||||||
|
|
||||||
|
for (const [token, reason] of [
|
||||||
|
["not-a-jwt", "INVALID_TOKEN"],
|
||||||
|
[forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"],
|
||||||
|
] as const) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`,
|
||||||
|
{ redirect: "manual" },
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const landing = new URL(response.headers.get("location") ?? "");
|
||||||
|
// The failure still lands on the host's own screen rather than a raw JSON error page.
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("error")).toBe(reason);
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when a link is followed twice, because the token is a signed claim", async () => {
|
||||||
|
const install = await installation("replay");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Replay", password: initialPassword });
|
||||||
|
await signIn(install.origin, email, initialPassword);
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||||
|
|
||||||
|
const first = await fetch(link, { redirect: "manual" });
|
||||||
|
const second = await fetch(link, { redirect: "manual" });
|
||||||
|
|
||||||
|
// Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed
|
||||||
|
// database row, so it is **not** single-use — it is valid until it expires. Replaying it is a
|
||||||
|
// no-op on an already-verified address and mints no session, which is what makes that safe.
|
||||||
|
// Mail scanners and prefetchers dereference links, so idempotence is a requirement here.
|
||||||
|
expect(first.status).toBe(302);
|
||||||
|
expect(second.status).toBe(302);
|
||||||
|
expect(second.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resends on request without revealing whether the address has an account", async () => {
|
||||||
|
const install = await installation("resend");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Resend", password: initialPassword });
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const known = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||||
|
const unknown = await post(install.origin, "/api/auth/send-verification-email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identical answers. The only difference anywhere is that a message was delivered, which the
|
||||||
|
// requester cannot observe.
|
||||||
|
expect(known.status).toBe(200);
|
||||||
|
expect(unknown.status).toBe(200);
|
||||||
|
expect(verifiedAlready.status).toBe(200);
|
||||||
|
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||||
|
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password recovery, end to end", () => {
|
||||||
|
it("replaces the password through a real emailed link and invalidates the old one", async () => {
|
||||||
|
const install = await installation("reset");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const requested = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
expect(requested.status).toBe(200);
|
||||||
|
expect(mail.delivered).toHaveLength(1);
|
||||||
|
expect(lastMessage(mail).to).toEqual([email]);
|
||||||
|
expect(lastMessage(mail).data).toContain("Reset your Ekklesia password");
|
||||||
|
|
||||||
|
// Follow the emailed link. Better Auth validates the token and hands the browser on to the
|
||||||
|
// Dashboard landing with it attached — which is where the host's reset form picks it up.
|
||||||
|
const link = extractLink(lastMessage(mail), "/reset-password/");
|
||||||
|
expect(link.origin).toBe(install.origin);
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
expect(handoff.status).toBe(302);
|
||||||
|
const landing = new URL(handoff.headers.get("location") ?? "");
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
const token = landing.searchParams.get("token") ?? "";
|
||||||
|
expect(token).not.toBe("");
|
||||||
|
|
||||||
|
const completed = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
expect(completed.status).toBe(200);
|
||||||
|
|
||||||
|
const oldCredential = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(oldCredential.response.status).toBe(401);
|
||||||
|
const newCredential = await signIn(install.origin, email, replacementPassword);
|
||||||
|
expect(newCredential.response.status).toBe(200);
|
||||||
|
expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes the reset token, so a captured link cannot be replayed", async () => {
|
||||||
|
const install = await installation("reset-replay");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const first = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
const second = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: "yet another different passphrase",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlike the verification token, this one is a database row Better Auth consumes.
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(second.status).toBe(400);
|
||||||
|
expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" });
|
||||||
|
expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs out every existing session for that account", async () => {
|
||||||
|
const install = await installation("reset-sessions");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const before = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated");
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of recovery is that it works when the credential is already compromised, so
|
||||||
|
// an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`).
|
||||||
|
expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the identity system's own password policy, with no reset-specific rule", async () => {
|
||||||
|
const install = await installation("reset-policy");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const tooShort = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: "short",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tooShort.status).toBe(400);
|
||||||
|
expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" });
|
||||||
|
// The old password still works: a rejected reset changes nothing.
|
||||||
|
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a request for an address with no account exactly as it answers a real one", async () => {
|
||||||
|
const install = await installation("enumeration");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const known = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const unknown = await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(known.status).toBe(unknown.status);
|
||||||
|
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||||
|
expect(JSON.stringify(await unknown.json())).not.toContain("stranger");
|
||||||
|
// Only the real account received anything.
|
||||||
|
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recovery when the installation has no outbound email", () => {
|
||||||
|
it("answers identically and records the operational failure out of band", async () => {
|
||||||
|
const install = await installation("no-mail", { mail: false });
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||||
|
|
||||||
|
const reset = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const resend = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||||
|
|
||||||
|
// The user-facing answer must not change: "we could not send it" for a real address and a
|
||||||
|
// generic success for an unknown one is an enumeration oracle that appears when SMTP breaks.
|
||||||
|
expect(reset.status).toBe(200);
|
||||||
|
expect(resend.status).toBe(200);
|
||||||
|
// The operator still learns about it, with a sanitized code and no address or token.
|
||||||
|
expect(install.notificationFailures).toEqual([
|
||||||
|
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(install.notificationFailures)).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still starts, serves the API, and refuses an unverified sign-in", async () => {
|
||||||
|
const install = await installation("no-mail-runs", { mail: false });
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||||
|
|
||||||
|
// Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification
|
||||||
|
// policy — which is exactly why the initial owner is created already verified.
|
||||||
|
expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200);
|
||||||
|
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recovery cannot be turned into a redirect or an account factory", () => {
|
||||||
|
it("refuses an attacker-supplied reset destination", async () => {
|
||||||
|
const install = await installation("redirect");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Redirect", password: initialPassword });
|
||||||
|
|
||||||
|
const external = await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email,
|
||||||
|
redirectTo: "https://evil.example.test/steal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external.status).toBe(403);
|
||||||
|
expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" });
|
||||||
|
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an attacker-supplied verification destination", async () => {
|
||||||
|
const install = await installation("redirect-verify");
|
||||||
|
|
||||||
|
for (const callbackURL of [
|
||||||
|
"https://evil.example.test",
|
||||||
|
"//evil.example.test",
|
||||||
|
"/\\evil.example.test",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
]) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||||
|
{ redirect: "manual" },
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.headers.get("location")).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => {
|
||||||
|
const install = await installation("redirect-ignored");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
|
||||||
|
// A *trusted* origin passes Better Auth's own check, so this is the case where the emailed
|
||||||
|
// link's destination would otherwise be caller-chosen. It is not: the message is built from
|
||||||
|
// configuration and the supplied value never reaches it.
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email,
|
||||||
|
redirectTo: `${dashboardOrigin}/somewhere-else`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const callback = new URL(link.searchParams.get("callbackURL") ?? "");
|
||||||
|
expect(callback.origin).toBe(dashboardOrigin);
|
||||||
|
expect(callback.pathname).toBe("/");
|
||||||
|
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
expect(link.href).not.toContain("somewhere-else");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps public signup disabled now that verification exists", async () => {
|
||||||
|
const install = await installation("signup");
|
||||||
|
|
||||||
|
const response = await post(install.origin, "/api/auth/sign-up/email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "Intruder",
|
||||||
|
password: initialPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email verification is not a licence to self-register. Future accounts arrive through
|
||||||
|
// deliberate administration, which this pass does not build.
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" });
|
||||||
|
expect(await emailVerifiedFlag(install.url, "[email protected]")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("managed devices are untouched by human email identity", () => {
|
||||||
|
it("leaves a paired device intact across a human password reset", async () => {
|
||||||
|
const install = await installation("devices");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
|
||||||
|
// A device is a row with a credential digest and no email, no password, and no verification
|
||||||
|
// state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove
|
||||||
|
// something this test is not about.
|
||||||
|
const client = new Client({ connectionString: install.url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
client,
|
||||||
|
'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)',
|
||||||
|
["Sanctuary display", "a".repeat(64)],
|
||||||
|
);
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>(
|
||||||
|
client,
|
||||||
|
'select "credential_hash", "revoked_at" from "ekklesia_core_device"',
|
||||||
|
);
|
||||||
|
expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]);
|
||||||
|
|
||||||
|
// The device table has no email or verification concept for a human flow to reach.
|
||||||
|
const columns = await safeQuery<{ column_name: string }>(
|
||||||
|
client,
|
||||||
|
"select column_name from information_schema.columns where table_name = 'ekklesia_core_device'",
|
||||||
|
);
|
||||||
|
const names = columns.map((column) => column.column_name).join(" ");
|
||||||
|
expect(names).not.toContain("email");
|
||||||
|
expect(names).not.toContain("verified");
|
||||||
|
expect(names).not.toContain("password");
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a device no mail, because a device has no address to send to", async () => {
|
||||||
|
const install = await installation("devices-mail");
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing about device identity participates in either flow.
|
||||||
|
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||||
|
expect(install.notificationFailures).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,7 @@ function environment(
|
|||||||
|
|
||||||
const configuredInfrastructure = Object.freeze({
|
const configuredInfrastructure = Object.freeze({
|
||||||
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||||
|
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||||
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||||
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
||||||
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
||||||
@@ -77,6 +78,9 @@ describe("host configuration parity between startApi and Doctor", () => {
|
|||||||
|
|
||||||
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
||||||
expect(apiView(env).mail).toMatchObject({
|
expect(apiView(env).mail).toMatchObject({
|
||||||
|
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
|
||||||
|
// must fail in Doctor for the same reason it fails at runtime.
|
||||||
|
heloName: "mail.example.org",
|
||||||
host: "smtp.example.org",
|
host: "smtp.example.org",
|
||||||
port: 587,
|
port: 587,
|
||||||
security: "starttls",
|
security: "starttls",
|
||||||
@@ -123,6 +127,10 @@ describe("host configuration parity between startApi and Doctor", () => {
|
|||||||
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
||||||
],
|
],
|
||||||
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
|
||||||
|
"MAIL_HELO_NAME_INVALID",
|
||||||
|
],
|
||||||
] as const) {
|
] as const) {
|
||||||
const env = environment(overrides);
|
const env = environment(overrides);
|
||||||
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
||||||
|
|||||||
@@ -170,9 +170,13 @@ function bootstrapAgainst(url: string): BootstrapHandles {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The production posture: public signup disabled, exactly as a real deployment must run. */
|
/**
|
||||||
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
* The production posture: public signup disabled, exactly as a real deployment must run — and, just
|
||||||
const config = createApiRuntimeConfig(
|
* as deliberately, **no outbound email configured at all**. A fresh installation has no relay yet,
|
||||||
|
* which is the whole reason the initial owner is created already verified (ADR 0038).
|
||||||
|
*/
|
||||||
|
function ownerApiConfig(url: string): ReturnType<typeof createApiRuntimeConfig> {
|
||||||
|
return createApiRuntimeConfig(
|
||||||
{
|
{
|
||||||
databaseUrl: url,
|
databaseUrl: url,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
@@ -186,7 +190,10 @@ async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
|||||||
},
|
},
|
||||||
{ environment: "development" },
|
{ environment: "development" },
|
||||||
);
|
);
|
||||||
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
}
|
||||||
|
|
||||||
|
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||||
|
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiOrigin(running: RunningApi): string {
|
function apiOrigin(running: RunningApi): string {
|
||||||
@@ -270,6 +277,22 @@ async function countRows(url: string, table: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Better Auth's own `emailVerified` column — the same value its `/verify-email` endpoint writes. */
|
||||||
|
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||||
|
client,
|
||||||
|
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
return rows[0]?.email_verified;
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const admin = await openAdministrativeClient().catch(() => undefined);
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
@@ -685,3 +708,141 @@ describe.sequential("initial platform-owner bootstrap", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.sequential("initial platform-owner bootstrap and email verification", () => {
|
||||||
|
/**
|
||||||
|
* The trusted-bootstrap verification exception (ADR 0038).
|
||||||
|
*
|
||||||
|
* A verified address became a precondition for holding a session in Phase 7C Pass 2. The initial
|
||||||
|
* owner is created on a freshly provisioned server, by an operator holding the database
|
||||||
|
* credentials, at a moment when SMTP may not exist — so requiring an email round trip here would
|
||||||
|
* produce a working installation nobody can ever sign in to. These scenarios exist to prove that
|
||||||
|
* the exception is real, that it is narrow, and that mail remains genuinely absent throughout.
|
||||||
|
*/
|
||||||
|
it("creates the owner already verified, proven in the database and in the browser", async () => {
|
||||||
|
const url = await freshInstallation("verifiedowner");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database state: the exact column Better Auth's own verification endpoint writes, so the row
|
||||||
|
// is indistinguishable from one that verified normally.
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(true);
|
||||||
|
|
||||||
|
// ...and the browser-facing consequence, which is the only thing that actually matters.
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
const session = await signIn(origin, email, ownerPassword);
|
||||||
|
expect(session.response.status).toBe(200);
|
||||||
|
expect(session.cookiePair).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs no outbound email configured, at bootstrap or at sign-in", async () => {
|
||||||
|
const url = await freshInstallation("nomailowner");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
// No SMTP host, no relay, no network: `createBootstrapIdentityCreator` constructs its own
|
||||||
|
// Better Auth instance with no `emailVerification` configuration at all.
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
// The API this suite starts configures no mail either, so nothing in this scenario could have
|
||||||
|
// sent or received a message.
|
||||||
|
expect(ownerApiConfig(url).mail).toBeUndefined();
|
||||||
|
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an adopted identity verified too, so bootstrap never yields an owner who cannot sign in", async () => {
|
||||||
|
const url = await freshInstallation("adoptverified");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
// An identity that exists before bootstrap runs — created here through the same trusted creator,
|
||||||
|
// which always writes `emailVerified: false`.
|
||||||
|
const first = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await first.run(email);
|
||||||
|
} finally {
|
||||||
|
await first.close();
|
||||||
|
}
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(client, 'update "ekklesia_core_user" set "email_verified" = false');
|
||||||
|
await safeQuery(client, 'delete from "ekklesia_core_platform_owner_bootstrap"');
|
||||||
|
await safeQuery(client, 'delete from "ekklesia_core_platform_owner"');
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(false);
|
||||||
|
|
||||||
|
const second = bootstrapAgainst(url);
|
||||||
|
let adopted: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
|
||||||
|
try {
|
||||||
|
adopted = await second.run(email);
|
||||||
|
} finally {
|
||||||
|
await second.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(adopted.identity).toBe("adopted");
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies only the owner, leaving every other account subject to the normal policy", async () => {
|
||||||
|
const url = await freshInstallation("narrowexception");
|
||||||
|
const ownerEmail = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const otherEmail = `member-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(ownerEmail);
|
||||||
|
// A second identity created through the identical trusted creator, but never bootstrapped.
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await createBootstrapIdentityCreator(identity.db)({
|
||||||
|
email: otherEmail,
|
||||||
|
name: "Ordinary Member",
|
||||||
|
password: ownerPassword,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await identity.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await emailVerifiedFlag(url, ownerEmail)).toBe(true);
|
||||||
|
expect(await emailVerifiedFlag(url, otherEmail)).toBe(false);
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
expect((await signIn(origin, ownerEmail, ownerPassword)).response.status).toBe(200);
|
||||||
|
// The exception did not widen: an ordinary account still has to verify, and on this
|
||||||
|
// mail-less installation it simply cannot sign in yet.
|
||||||
|
const refused = await signIn(origin, otherEmail, ownerPassword);
|
||||||
|
expect(refused.response.status).toBe(403);
|
||||||
|
expect(await refused.response.json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||||
|
expect(refused.cookiePair).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
|||||||
const serveMock = vi.hoisted(() => vi.fn());
|
const serveMock = vi.hoisted(() => vi.fn());
|
||||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||||
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||||
|
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
|
||||||
|
// so the loader answers a small consistent snapshot unless a test makes it fail.
|
||||||
|
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||||
|
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||||
|
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||||
|
);
|
||||||
|
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||||
|
});
|
||||||
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||||
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||||
@@ -35,7 +45,13 @@ import {
|
|||||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
|
||||||
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||||
|
import {
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||||
|
|
||||||
function validConfig() {
|
function validConfig() {
|
||||||
@@ -88,7 +104,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -254,12 +270,13 @@ function fakeDiscoveredModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
@@ -446,6 +463,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
|
||||||
|
const query = emptyQuery();
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig());
|
||||||
|
query.mockClear();
|
||||||
|
|
||||||
|
const ready = await running.app.request(HEALTH_READY_PATH);
|
||||||
|
expect(ready.status).toBe(200);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
|
||||||
|
// cancels a stalled ping rather than the route merely abandoning it.
|
||||||
|
expect(query).toHaveBeenCalledWith(
|
||||||
|
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shutdown = running.shutdown();
|
||||||
|
const draining = await running.app.request(HEALTH_READY_PATH);
|
||||||
|
expect(draining.status).toBe(503);
|
||||||
|
expect(await draining.json()).toEqual({ code: "NOT_READY" });
|
||||||
|
// Draining is not dead: liveness still answers while in-flight requests finish.
|
||||||
|
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
await shutdown;
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
@@ -1021,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
expect(serveMock).not.toHaveBeenCalled();
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
|
||||||
|
|
||||||
|
const failure = await startApi(validConfig(), {
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
}).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not read display-host assets when the eligible set declares no display", async () => {
|
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|||||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import {
|
import {
|
||||||
createFakeModuleSecretSource,
|
createFakeModuleSecretSource,
|
||||||
createInMemoryModuleConfigurationStore,
|
createInMemoryModuleConfigurationStore,
|
||||||
@@ -106,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
configuration: fixture.configuration ?? [],
|
configuration: fixture.configuration ?? [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -189,6 +191,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleConfiguration: {
|
moduleConfiguration: {
|
||||||
bootSettings,
|
bootSettings,
|
||||||
discoveredModules: modules,
|
discoveredModules: modules,
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -253,6 +254,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
"db-facade:0000_initial",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
@@ -319,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
|
|||||||
return { userId: body.user.id };
|
return { userId: body.user.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
|
||||||
|
*
|
||||||
|
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
|
||||||
|
* module database access rather than identity, so it completes that step directly rather than
|
||||||
|
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
|
||||||
|
* `email-verification.integration.ts`.
|
||||||
|
*/
|
||||||
|
async function markEmailVerified(url: string, email: string): Promise<void> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
||||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
@@ -507,6 +530,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
|||||||
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||||
|
|
||||||
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
||||||
|
await markEmailVerified(database.url, "[email protected]");
|
||||||
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
||||||
expect(signedUp.userId).toBeDefined();
|
expect(signedUp.userId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
DISPLAY_HOST_SCRIPT_PATH,
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
DISPLAY_SERVICE_WORKER_PATH,
|
||||||
ModuleDisplayConfigurationError,
|
ModuleDisplayConfigurationError,
|
||||||
moduleDisplayDescriptorUrl,
|
moduleDisplayDescriptorUrl,
|
||||||
moduleDisplayPageUrl,
|
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 { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
import {
|
import {
|
||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
@@ -51,6 +54,7 @@ const displayPermission = "announcements.display.view";
|
|||||||
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||||
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
`<!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.__ekklesiaDisplayHostLoaded = true;\n",
|
||||||
|
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
@@ -101,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: fixture.display
|
permissions: fixture.display
|
||||||
? [
|
? [
|
||||||
@@ -196,6 +200,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
store: fakeAuthorizationStore(),
|
store: fakeAuthorizationStore(),
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||||
? { displayHostAssets: options.assets ?? displayHostAssets }
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -266,11 +272,13 @@ describe("module display routes — authorization and descriptor", () => {
|
|||||||
expect(await descriptor.json()).toEqual({
|
expect(await descriptor.json()).toEqual({
|
||||||
viewer: { kind: "authenticated", userId: viewerId },
|
viewer: { kind: "authenticated", userId: viewerId },
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
display: {
|
display: {
|
||||||
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
},
|
},
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
|
page: { url: moduleDisplayPageUrl("announcements") },
|
||||||
permissions: [displayPermission],
|
permissions: [displayPermission],
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||||
version: "1.4.2",
|
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(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
expect(
|
expect(
|
||||||
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
|
(
|
||||||
.modules,
|
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
).toHaveLength(1);
|
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 () => {
|
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 () => {
|
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 mutable = { ...source };
|
||||||
const { app } = await install({ assets: mutable, display: true });
|
const { app } = await install({ assets: mutable, display: true });
|
||||||
mutable.html = "<html>mutated secret</html>";
|
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);
|
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";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
canonicalizeVaryHeader,
|
canonicalizeVaryHeader,
|
||||||
@@ -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
|
* 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"
|
* 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(
|
function moduleRoute(
|
||||||
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
||||||
|
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
||||||
|
maxBodyBytes?: number;
|
||||||
|
},
|
||||||
): RegisteredModuleHttpRoute {
|
): 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,
|
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||||
@@ -120,6 +125,8 @@ function buildModuleApp(options: {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
});
|
});
|
||||||
@@ -1634,3 +1641,375 @@ describe("module route overlap can never reach mounting", () => {
|
|||||||
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
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_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
@@ -77,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -171,6 +173,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import {
|
|||||||
discoverModules,
|
discoverModules,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} 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 {
|
import {
|
||||||
bootstrapDisplayHost,
|
bootstrapDisplayHost,
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
@@ -30,6 +36,8 @@ import {
|
|||||||
} from "@ekklesia/shell";
|
} from "@ekklesia/shell";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
@@ -121,7 +129,7 @@ async function createFixtureModule(
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name,
|
name,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
@@ -165,6 +173,7 @@ async function createFixtureModule(
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
@@ -184,6 +193,7 @@ async function createFixtureModule(
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/display/index.js"),
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
@@ -270,6 +280,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
displayHostAssets: createDisplayHostAssets(
|
displayHostAssets: createDisplayHostAssets(
|
||||||
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||||
"export const displayHost = true;\n",
|
"export const displayHost = true;\n",
|
||||||
|
"export const displayWorker = true;\n",
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
@@ -279,6 +290,8 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: ["https://dashboard.example.test"],
|
trustedOrigins: ["https://dashboard.example.test"],
|
||||||
});
|
});
|
||||||
@@ -310,10 +323,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
|
|||||||
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
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. */
|
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
|
||||||
function containerSink(): { textContent: string } {
|
* test environment.
|
||||||
return { textContent: "" };
|
*
|
||||||
|
* 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"];
|
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.
|
// 2. The URLs came from the server, not from anything the browser composed.
|
||||||
expect(descriptor).toEqual({
|
expect(descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read"],
|
permissions: ["example.records.read"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
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.
|
// 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.
|
// 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"]);
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
|
|
||||||
// 7. And it tears down cleanly.
|
// 7. And it tears down cleanly.
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(mountedText(container)).toBe("");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
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({
|
expect(bootstrap.descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
|
displayPageUrl: "/display/example",
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read", "example.display.view"],
|
permissions: ["example.records.read", "example.display.view"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
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);
|
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||||
|
|
||||||
// 4. The generic loader receives the unchanged five-member browser context and mounts the
|
// 4. The generic loader receives the host-owned browser context, including the canonical
|
||||||
// display artifact independently of the Dashboard artifact.
|
// display page, and mounts the display artifact independently of the Dashboard artifact.
|
||||||
const context = createModuleUiContext({
|
const context = createModuleUiContext({
|
||||||
config: harness.config,
|
config: harness.config,
|
||||||
descriptor: bootstrap.descriptor,
|
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([
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
|
"display",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"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"]);
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(mountedText(container)).toBe("");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
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.
|
// 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([]);
|
expect(harness.moduleApiCalls).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
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,
|
version: groupedEntry.descriptor.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(container.textContent).toBe("example:200:2");
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
|
|
||||||
// Now move it somewhere else entirely.
|
// 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,
|
version: movedEntry.descriptor.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(movedContainer.textContent).toBe("example:200:2");
|
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||||
"GET /api/v1/modules/example/records",
|
"GET /api/v1/modules/example/records",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||||
@@ -59,6 +61,12 @@ interface ModuleFixture {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly baseline: "authenticated" | "public" | "restricted";
|
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> {
|
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",
|
server: "./dist/server/index.js",
|
||||||
...(fixture.withUi ? { ui: "./dist/ui/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,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||||
...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`,
|
`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 {
|
interface InstallOptions {
|
||||||
@@ -185,6 +210,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
@@ -219,7 +246,9 @@ describe("module UI inventory — what it lists", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(modules).toEqual([
|
expect(modules).toEqual([
|
||||||
{
|
{
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/example" },
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
|
display: null,
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
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.
|
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
||||||
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
|
"display",
|
||||||
"id",
|
"id",
|
||||||
"permissions",
|
"permissions",
|
||||||
"realtime",
|
"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,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||||
|
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
MODULE_UI_ASSET_PREFIX,
|
MODULE_UI_ASSET_PREFIX,
|
||||||
MODULE_UI_CACHE_CONTROL,
|
MODULE_UI_CACHE_CONTROL,
|
||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
|
moduleContributionArtifactUrl,
|
||||||
moduleDisplayUiArtifactUrl,
|
moduleDisplayUiArtifactUrl,
|
||||||
moduleUiArtifactUrl,
|
moduleUiArtifactUrl,
|
||||||
} from "../src/module-ui-routes.js";
|
} 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. */
|
* sequence whose exact publication and loader-compatibility can be asserted. */
|
||||||
function artifactSource(id: string, version: string): string {
|
function artifactSource(id: string, version: string): string {
|
||||||
return `export default {
|
return `export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: ${JSON.stringify(version)},
|
version: ${JSON.stringify(version)},
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
@@ -83,6 +89,15 @@ interface ModuleFixture {
|
|||||||
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
* 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. */
|
* files that genuinely exist on disk rather than against hypothetical ones. */
|
||||||
readonly extraFiles?: Readonly<Record<string, string>>;
|
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> {
|
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,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
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 mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
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) {
|
if (fixture.display !== undefined) {
|
||||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||||
@@ -209,6 +242,7 @@ async function install(
|
|||||||
displayHostAssets: createDisplayHostAssets(
|
displayHostAssets: createDisplayHostAssets(
|
||||||
'<!doctype html><div id="display-root"></div>',
|
'<!doctype html><div id="display-root"></div>',
|
||||||
"export {};\n",
|
"export {};\n",
|
||||||
|
"export {};\n",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -223,6 +257,8 @@ async function install(
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
@@ -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", () => {
|
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
|
* 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,
|
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
||||||
* so a plain sink is enough and no DOM implementation is required. The container's type is read
|
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
||||||
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
|
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
||||||
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
|
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
||||||
* route exists to keep apart.
|
|
||||||
*/
|
*/
|
||||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
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() {
|
function hostContext() {
|
||||||
return {
|
return {
|
||||||
api: { request: async () => new Response(null, { status: 200 }) },
|
api: { request: async () => new Response(null, { status: 200 }) },
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: undefined,
|
||||||
moduleId: "announcements",
|
moduleId: "announcements",
|
||||||
permissions: { has: () => false },
|
permissions: { has: () => false },
|
||||||
principal: { kind: "anonymous" as const },
|
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" },
|
source: { moduleId: "announcements", url, version: "1.4.2" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("announcements:mounted");
|
expect(mountedText(container)).toBe("announcements:mounted");
|
||||||
await mounted.unmount();
|
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 () => {
|
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" });
|
).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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Email verification becomes a precondition for holding a session (ADR 0038). Any platform owner
|
||||||
|
-- that already exists was created by the trusted operator-run bootstrap command before that command
|
||||||
|
-- marked addresses verified, so without this they would be locked out of their own installation by
|
||||||
|
-- an upgrade.
|
||||||
|
--
|
||||||
|
-- Deliberately narrow. It marks exactly the identities the authorization tables already record as
|
||||||
|
-- currently-granted platform owners: an authority the deployment established through a command that
|
||||||
|
-- required database credentials, which is the same authority the bootstrap verification exception
|
||||||
|
-- rests on. Every other account is left untouched and must verify by email like any other.
|
||||||
|
--
|
||||||
|
-- Data-only and idempotent. It adds no column, drops nothing, and re-running it changes no row that
|
||||||
|
-- is not already false.
|
||||||
|
UPDATE "ekklesia_core_user"
|
||||||
|
SET "email_verified" = true
|
||||||
|
WHERE "email_verified" = false
|
||||||
|
AND "id" IN (
|
||||||
|
SELECT "user_id"
|
||||||
|
FROM "ekklesia_core_platform_owner"
|
||||||
|
WHERE "revoked_at" IS NULL
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,12 @@
|
|||||||
"destructive": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0007_verified_bootstrap_owners",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,13 @@
|
|||||||
"when": 1787543773057,
|
"when": 1787543773057,
|
||||||
"tag": "0006_device_identity",
|
"tag": "0006_device_identity",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787543773058,
|
||||||
|
"tag": "0007_verified_bootstrap_owners",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type InitialPlatformOwnerErrorCode =
|
|||||||
| "INITIAL_OWNER_EMAIL_INVALID"
|
| "INITIAL_OWNER_EMAIL_INVALID"
|
||||||
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
||||||
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
||||||
|
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
|
||||||
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
||||||
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
||||||
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
||||||
@@ -28,6 +29,8 @@ const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, strin
|
|||||||
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
||||||
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
||||||
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
||||||
|
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
|
||||||
|
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
|
||||||
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
||||||
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
||||||
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||||
@@ -131,6 +134,25 @@ async function findIdentityByEmail(
|
|||||||
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
|
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks one identity's address verified.
|
||||||
|
*
|
||||||
|
* A direct column update rather than an identity-system call, because the pinned release exposes no
|
||||||
|
* supported way to mark an address verified other than consuming a token it emailed — which is the
|
||||||
|
* one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the
|
||||||
|
* exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from
|
||||||
|
* one that verified normally; nothing here invents a parallel notion of verification.
|
||||||
|
*/
|
||||||
|
async function markIdentityEmailVerified(
|
||||||
|
db: IdentityDrizzleDatabase,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(ekklesiaCoreUser)
|
||||||
|
.set({ emailVerified: true })
|
||||||
|
.where(eq(ekklesiaCoreUser.id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
|
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
|
||||||
if (error instanceof BootstrapIdentityError) {
|
if (error instanceof BootstrapIdentityError) {
|
||||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
||||||
@@ -252,6 +274,29 @@ export async function createInitialPlatformOwner(
|
|||||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trusted-bootstrap verification exception (ADR 0038).
|
||||||
|
*
|
||||||
|
* Every other human account must verify its address by email before it may hold a session.
|
||||||
|
* This one cannot: it is created on a freshly provisioned server by an operator who already
|
||||||
|
* holds the database credentials, at a moment when SMTP may not be configured, may not be
|
||||||
|
* reachable, or may not yet have a DNS record to send from. Requiring an email round trip here
|
||||||
|
* would mean a working installation whose administrator can never get in — the platform's
|
||||||
|
* outbound mail would have to be configured and correct before anyone could sign in to
|
||||||
|
* configure anything.
|
||||||
|
*
|
||||||
|
* The exception is narrow and its authority is the same authority that already makes this
|
||||||
|
* command safe: possession of the database, not possession of the mailbox. It marks exactly one
|
||||||
|
* address, exactly once per installation, and it is deliberately performed **before** ownership
|
||||||
|
* is granted — if it fails, the sentinel and the grant roll back together and re-running the
|
||||||
|
* command adopts the identity and finishes, rather than leaving an owner who cannot sign in.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data);
|
||||||
|
} catch {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const grant =
|
const grant =
|
||||||
dependencies.grantOwnership ??
|
dependencies.grantOwnership ??
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { PermissionRegistryError } from "../authorization/index.js";
|
import { PermissionRegistryError } from "../authorization/index.js";
|
||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
ModulePermissionCompositionError,
|
ModulePermissionCompositionError,
|
||||||
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
|
|||||||
return;
|
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(
|
console.log(
|
||||||
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
||||||
);
|
);
|
||||||
for (const discoveredModule of modules) {
|
for (const discoveredModule of modules) {
|
||||||
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
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,
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleDatabase,
|
createModuleLifecycleDatabase,
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
@@ -116,6 +117,7 @@ export interface DoctorReport {
|
|||||||
|
|
||||||
export interface DoctorIdentityConfigurationOptions {
|
export interface DoctorIdentityConfigurationOptions {
|
||||||
readonly baseUrl?: string | undefined;
|
readonly baseUrl?: string | undefined;
|
||||||
|
readonly dashboardOrigin?: string | undefined;
|
||||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||||
readonly publicSignupEnabled?: string | undefined;
|
readonly publicSignupEnabled?: string | undefined;
|
||||||
readonly secret?: string | undefined;
|
readonly secret?: string | undefined;
|
||||||
@@ -315,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
|||||||
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
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[] {
|
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) => {
|
return modules.map((module) => {
|
||||||
const id = module.manifest.id;
|
const id = module.manifest.id;
|
||||||
const version = module.manifest.version;
|
const version = module.manifest.version;
|
||||||
@@ -325,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
|
|||||||
) {
|
) {
|
||||||
throw new TypeError("Unsafe module diagnostic value.");
|
throw new TypeError("Unsafe module diagnostic value.");
|
||||||
}
|
}
|
||||||
|
const declared = module.contributions.length;
|
||||||
|
if (declared === 0) {
|
||||||
return `${id} ${version}`;
|
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)`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,6 +757,35 @@ function identitySchemaSummary(): string {
|
|||||||
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045).
|
||||||
|
*
|
||||||
|
* The same hostname on another port is the same site. Otherwise the two hostnames must be siblings:
|
||||||
|
* identical after their first label, with that parent itself holding at least one dot
|
||||||
|
* (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in
|
||||||
|
* both directions for unusual names, which is why its result is a warning and never a failure.
|
||||||
|
*/
|
||||||
|
function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean {
|
||||||
|
const dashboard = new URL(dashboardOrigin).hostname;
|
||||||
|
const api = new URL(apiOrigin).hostname;
|
||||||
|
if (dashboard === api) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// An IP address is a site of its own; two different ones never share one.
|
||||||
|
const isIpAddress = (hostname: string): boolean =>
|
||||||
|
hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname);
|
||||||
|
if (isIpAddress(dashboard) || isIpAddress(api)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1);
|
||||||
|
return (
|
||||||
|
dashboard.includes(".") &&
|
||||||
|
api.includes(".") &&
|
||||||
|
parent(dashboard) === parent(api) &&
|
||||||
|
parent(dashboard).includes(".")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
||||||
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
||||||
@@ -739,6 +798,7 @@ function identityConfigurationCheck(
|
|||||||
const hasAnyValue =
|
const hasAnyValue =
|
||||||
input?.secret !== undefined ||
|
input?.secret !== undefined ||
|
||||||
input?.baseUrl !== undefined ||
|
input?.baseUrl !== undefined ||
|
||||||
|
(input?.dashboardOrigin ?? "") !== "" ||
|
||||||
input?.publicSignupEnabled !== undefined ||
|
input?.publicSignupEnabled !== undefined ||
|
||||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||||
|
|
||||||
@@ -753,17 +813,43 @@ function identityConfigurationCheck(
|
|||||||
const config = createIdentityConfig(
|
const config = createIdentityConfig(
|
||||||
{
|
{
|
||||||
baseUrl: input?.baseUrl,
|
baseUrl: input?.baseUrl,
|
||||||
|
// Validated exactly as the API validates it at startup, so a Dashboard origin the API would
|
||||||
|
// refuse (untrusted, insecure, malformed) fails here first.
|
||||||
|
dashboardOrigin: input?.dashboardOrigin,
|
||||||
publicSignupEnabled: input?.publicSignupEnabled,
|
publicSignupEnabled: input?.publicSignupEnabled,
|
||||||
secret: input?.secret,
|
secret: input?.secret,
|
||||||
trustedOrigins: input?.trustedOrigins,
|
trustedOrigins: input?.trustedOrigins,
|
||||||
},
|
},
|
||||||
{ environment: input?.environment ?? "production" },
|
{ environment: input?.environment ?? "production" },
|
||||||
);
|
);
|
||||||
|
const signupDetail = config.publicSignupEnabled
|
||||||
|
? "Public signup is explicitly ENABLED."
|
||||||
|
: "Public signup is disabled (safe default).";
|
||||||
|
if (config.dashboardOrigin === config.baseUrl) {
|
||||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||||
schemaDetail,
|
schemaDetail,
|
||||||
config.publicSignupEnabled
|
signupDetail,
|
||||||
? "Public signup is explicitly ENABLED."
|
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||||
: "Public signup is disabled (safe default).",
|
]);
|
||||||
|
}
|
||||||
|
const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins).";
|
||||||
|
if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) {
|
||||||
|
return freezeCheck(
|
||||||
|
"identity.configuration",
|
||||||
|
"warning",
|
||||||
|
"Identity configuration valid; split origins may not share a site",
|
||||||
|
[
|
||||||
|
schemaDetail,
|
||||||
|
signupDetail,
|
||||||
|
topologyDetail,
|
||||||
|
"The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||||
|
schemaDetail,
|
||||||
|
signupDetail,
|
||||||
|
topologyDetail,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
@@ -1193,7 +1279,7 @@ export async function diagnoseEkklesia(
|
|||||||
"skipped",
|
"skipped",
|
||||||
"Outbound email not configured",
|
"Outbound email not configured",
|
||||||
[
|
[
|
||||||
"No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.",
|
"No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export type HostEnvironmentName = "development" | "production";
|
|||||||
*/
|
*/
|
||||||
export interface HostIdentityEnvironmentInput {
|
export interface HostIdentityEnvironmentInput {
|
||||||
readonly baseUrl: string | undefined;
|
readonly baseUrl: string | undefined;
|
||||||
|
/** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is
|
||||||
|
* served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */
|
||||||
|
readonly dashboardOrigin: string | undefined;
|
||||||
readonly publicSignupEnabled: string | undefined;
|
readonly publicSignupEnabled: string | undefined;
|
||||||
readonly secret: string | undefined;
|
readonly secret: string | undefined;
|
||||||
readonly trustedOrigins: readonly string[] | undefined;
|
readonly trustedOrigins: readonly string[] | undefined;
|
||||||
@@ -118,6 +121,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
|
|||||||
environment: environment.NODE_ENV === "development" ? "development" : "production",
|
environment: environment.NODE_ENV === "development" ? "development" : "production",
|
||||||
identity: {
|
identity: {
|
||||||
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
|
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
|
||||||
|
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
|
||||||
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
||||||
secret: environment.EKKLESIA_IDENTITY_SECRET,
|
secret: environment.EKKLESIA_IDENTITY_SECRET,
|
||||||
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
|
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
|
||||||
@@ -129,6 +133,10 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
|
|||||||
mail: {
|
mail: {
|
||||||
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
|
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
|
||||||
fromName: environment.EKKLESIA_SMTP_FROM_NAME,
|
fromName: environment.EKKLESIA_SMTP_FROM_NAME,
|
||||||
|
// The name this client announces in `EHLO`. Optional, and unset is the ordinary case: it
|
||||||
|
// matters only where the machine's own hostname is not a fully qualified name the relay will
|
||||||
|
// accept.
|
||||||
|
heloName: environment.EKKLESIA_SMTP_HELO_NAME,
|
||||||
host: environment.EKKLESIA_SMTP_HOST,
|
host: environment.EKKLESIA_SMTP_HOST,
|
||||||
password: environment.EKKLESIA_SMTP_PASSWORD,
|
password: environment.EKKLESIA_SMTP_PASSWORD,
|
||||||
port: environment.EKKLESIA_SMTP_PORT,
|
port: environment.EKKLESIA_SMTP_PORT,
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
|
|||||||
| "BASE_URL_INVALID"
|
| "BASE_URL_INVALID"
|
||||||
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
||||||
| "BASE_URL_MISSING"
|
| "BASE_URL_MISSING"
|
||||||
|
| "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"
|
||||||
|
| "DASHBOARD_ORIGIN_INVALID"
|
||||||
|
| "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"
|
||||||
|
| "DASHBOARD_ORIGIN_UNTRUSTED"
|
||||||
| "PUBLIC_SIGNUP_ENABLED_INVALID"
|
| "PUBLIC_SIGNUP_ENABLED_INVALID"
|
||||||
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
|
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
|
||||||
| "SECRET_LOW_ENTROPY"
|
| "SECRET_LOW_ENTROPY"
|
||||||
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
|
|||||||
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
||||||
"The identity base URL must not resolve to a loopback host in production.",
|
"The identity base URL must not resolve to a loopback host in production.",
|
||||||
BASE_URL_MISSING: "The identity base URL is required.",
|
BASE_URL_MISSING: "The identity base URL is required.",
|
||||||
|
DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.",
|
||||||
|
DASHBOARD_ORIGIN_INVALID:
|
||||||
|
"The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.",
|
||||||
|
DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION:
|
||||||
|
"The Dashboard origin must not resolve to a loopback host in production.",
|
||||||
|
DASHBOARD_ORIGIN_UNTRUSTED:
|
||||||
|
"The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.",
|
||||||
PUBLIC_SIGNUP_ENABLED_INVALID:
|
PUBLIC_SIGNUP_ENABLED_INVALID:
|
||||||
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
|
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
|
||||||
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
||||||
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
|
|||||||
|
|
||||||
export interface IdentityConfig {
|
export interface IdentityConfig {
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
|
/**
|
||||||
|
* The browser origin an emailed verification or password-reset link sends a person to.
|
||||||
|
*
|
||||||
|
* Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API
|
||||||
|
* are served from one origin. It exists as its own value because they are not the same *concept* —
|
||||||
|
* `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and
|
||||||
|
* a deployment may separate them (an API at `https://api.example.org`, the Dashboard at
|
||||||
|
* `https://dash.example.org`). It is deliberately **not** the public church website origin: this
|
||||||
|
* is the authenticated Dashboard host and nothing else.
|
||||||
|
*
|
||||||
|
* Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so
|
||||||
|
* a misconfiguration fails at startup rather than producing emailed links every recipient is
|
||||||
|
* refused at.
|
||||||
|
*/
|
||||||
|
readonly dashboardOrigin: string;
|
||||||
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
|
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
|
||||||
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
||||||
* loosely parsed. Development and test composition may enable it explicitly; production
|
* loosely parsed. Development and test composition may enable it explicitly; production
|
||||||
@@ -176,6 +202,8 @@ export interface IdentityConfig {
|
|||||||
|
|
||||||
export interface IdentityConfigInput {
|
export interface IdentityConfigInput {
|
||||||
readonly baseUrl?: unknown;
|
readonly baseUrl?: unknown;
|
||||||
|
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
|
||||||
|
readonly dashboardOrigin?: unknown;
|
||||||
readonly publicSignupEnabled?: unknown;
|
readonly publicSignupEnabled?: unknown;
|
||||||
readonly secret?: unknown;
|
readonly secret?: unknown;
|
||||||
readonly trustedOrigins?: unknown;
|
readonly trustedOrigins?: unknown;
|
||||||
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
|
|||||||
return url.origin;
|
return url.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the origin an emailed link points a browser at.
|
||||||
|
*
|
||||||
|
* Unset falls back to the identity base URL — the same-origin production shape — rather than
|
||||||
|
* failing, because a deployment that has not separated its Dashboard from its API has nothing to
|
||||||
|
* configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it
|
||||||
|
* must be an origin Better Auth already trusts, because Better Auth validates the emailed callback
|
||||||
|
* against `trustedOrigins` before redirecting. Checking it here converts a broken link every
|
||||||
|
* recipient would hit into a startup failure the operator sees once.
|
||||||
|
*/
|
||||||
|
function validateDashboardOrigin(
|
||||||
|
dashboardOrigin: unknown,
|
||||||
|
baseUrl: string,
|
||||||
|
trustedOrigins: readonly string[],
|
||||||
|
environment: IdentityEnvironment,
|
||||||
|
): string {
|
||||||
|
if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
const url = parseCanonicalHttpOrigin(dashboardOrigin);
|
||||||
|
if (!url) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID");
|
||||||
|
}
|
||||||
|
if (environment === "production") {
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the
|
||||||
|
// base URL's origin to its trusted set (`getTrustedOrigins`, pinned release).
|
||||||
|
if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED");
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
||||||
if (typeof origin !== "string" || origin.length === 0) {
|
if (typeof origin !== "string" || origin.length === 0) {
|
||||||
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
||||||
@@ -355,7 +422,19 @@ export function createIdentityConfig(
|
|||||||
const secret = validateSecret(input.secret);
|
const secret = validateSecret(input.secret);
|
||||||
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
||||||
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
||||||
|
const dashboardOrigin = validateDashboardOrigin(
|
||||||
|
input.dashboardOrigin,
|
||||||
|
baseUrl,
|
||||||
|
trustedOrigins,
|
||||||
|
environment,
|
||||||
|
);
|
||||||
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
||||||
|
|
||||||
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
|
return Object.freeze({
|
||||||
|
baseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
publicSignupEnabled,
|
||||||
|
secret,
|
||||||
|
trustedOrigins,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from "./bootstrap-identity.js";
|
|||||||
export * from "./client-address.js";
|
export * from "./client-address.js";
|
||||||
export * from "./config.js";
|
export * from "./config.js";
|
||||||
export * from "./database.js";
|
export * from "./database.js";
|
||||||
|
export * from "./notifications.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { MailError, type MailProvider, validateMailMessage } from "../mail/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity feature's outbound messages, and the seam between Better Auth's callbacks and the
|
||||||
|
* host's mail transport (ADR 0038).
|
||||||
|
*
|
||||||
|
* ## What this owns and what it deliberately does not
|
||||||
|
*
|
||||||
|
* It owns the recipient, the subject, and the two bodies. It does **not** know the SMTP host, the
|
||||||
|
* port, the TLS mode, a credential, or the sender: those are host configuration held by the mail
|
||||||
|
* provider, and there is no field on the message contract through which a caller could choose any
|
||||||
|
* of them (ADR 0037). Swapping SMTP for a provider HTTP API would not change a line of this file.
|
||||||
|
*
|
||||||
|
* It also does not own tokens. Better Auth mints, persists (for reset), expires, and consumes them;
|
||||||
|
* this receives one already-minted token and puts it in a link. There is no Ekklesia token table,
|
||||||
|
* no Ekklesia token hashing, and no Ekklesia expiry clock — a second implementation of any of those
|
||||||
|
* would be a second thing to get wrong.
|
||||||
|
*
|
||||||
|
* ## Why the link is rebuilt rather than used as given
|
||||||
|
*
|
||||||
|
* Better Auth hands these callbacks a prebuilt `url`, and that URL embeds whatever `callbackURL` or
|
||||||
|
* `redirectTo` the *requesting client* supplied. Better Auth does validate those against its trusted
|
||||||
|
* origins, and that validation is sound — but an emailed link is the one artifact a person is most
|
||||||
|
* likely to click without looking, so its destination is derived here from validated configuration
|
||||||
|
* alone and from nothing a request could influence. The token is the only value taken from Better
|
||||||
|
* Auth. No request parameter can steer where a recipient lands, independently of whether the origin
|
||||||
|
* check behaves as expected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What could not be delivered, for an operator — never who it was for, and never the link. */
|
||||||
|
export type IdentityNotificationKind = "password-reset" | "email-verification";
|
||||||
|
|
||||||
|
export interface IdentityNotificationFailure {
|
||||||
|
readonly kind: IdentityNotificationKind;
|
||||||
|
/** A sanitized `MailError` code, or `MAIL_SEND_FAILED` for anything unrecognized. */
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a delivery failure is reported.
|
||||||
|
*
|
||||||
|
* It exists because these callbacks must **never throw**. Better Auth's own
|
||||||
|
* `/send-verification-email` rethrows whatever the callback threw, and a thrown error there turns
|
||||||
|
* "this address has an unverified account" into a distinguishable response — an account-enumeration
|
||||||
|
* oracle that appears the moment SMTP breaks. So failure is swallowed at this boundary and surfaced
|
||||||
|
* out of band instead, where no HTTP response shape depends on it.
|
||||||
|
*
|
||||||
|
* The default implementation writes one fixed line per failure. It carries the flow and a sanitized
|
||||||
|
* code and nothing else: no address, no token, no URL, no provider text.
|
||||||
|
*/
|
||||||
|
export type IdentityNotificationReporter = (failure: IdentityNotificationFailure) => void;
|
||||||
|
|
||||||
|
export interface IdentityNotificationRecipient {
|
||||||
|
readonly email: string;
|
||||||
|
readonly name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIdentityNotificationsOptions {
|
||||||
|
/**
|
||||||
|
* The host mail provider, or `undefined` when this installation has none.
|
||||||
|
*
|
||||||
|
* `undefined` is a complete deployment (ADR 0037): the platform still runs, the initial owner
|
||||||
|
* still signs in, and only verification and recovery are unavailable — reported as
|
||||||
|
* `MAIL_NOT_CONFIGURED` through the reporter, never as a different HTTP response.
|
||||||
|
*/
|
||||||
|
readonly mail: MailProvider | undefined;
|
||||||
|
/** Where the `/api/auth` transport lives — the origin Better Auth's own endpoints are on. */
|
||||||
|
readonly authBaseUrl: string;
|
||||||
|
/** Where a human's browser lands. Validated to be a trusted origin (`./config.js`). */
|
||||||
|
readonly dashboardOrigin: string;
|
||||||
|
/** Defaults to a single fixed sanitized console line. */
|
||||||
|
readonly report?: IdentityNotificationReporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityNotifications {
|
||||||
|
/** Shaped for Better Auth's `emailVerification.sendVerificationEmail`. Never throws. */
|
||||||
|
readonly sendVerificationEmail: (data: {
|
||||||
|
readonly user: IdentityNotificationRecipient;
|
||||||
|
readonly token: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
/** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */
|
||||||
|
readonly sendResetPassword: (data: {
|
||||||
|
readonly user: IdentityNotificationRecipient;
|
||||||
|
readonly token: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two query values the Dashboard bundle reads to decide which host-owned auth screen to show.
|
||||||
|
*
|
||||||
|
* They are query parameters on the Dashboard's root path rather than distinct paths deliberately:
|
||||||
|
* the shell is a static single-page bundle, and a deep path would require every deployment's static
|
||||||
|
* host to rewrite unknown paths to `index.html` before an emailed link worked at all. A query
|
||||||
|
* parameter works on any static host, including the repository's own development server.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_AUTH_LANDING_PARAMETER = "ekklesia_auth";
|
||||||
|
export const DASHBOARD_VERIFY_EMAIL_LANDING = "verify-email";
|
||||||
|
export const DASHBOARD_RESET_PASSWORD_LANDING = "reset-password";
|
||||||
|
|
||||||
|
function landingUrl(dashboardOrigin: string, landing: string): string {
|
||||||
|
const url = new URL(dashboardOrigin);
|
||||||
|
url.searchParams.set(DASHBOARD_AUTH_LANDING_PARAMETER, landing);
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Better Auth's own verification URL, rebuilt from configuration.
|
||||||
|
*
|
||||||
|
* The shape is the pinned release's protocol exactly — `GET {authBase}/api/auth/verify-email?
|
||||||
|
* token=...&callbackURL=...` — so Better Auth remains the only thing that inspects, expires, or acts
|
||||||
|
* on the token. Only the destination is ours.
|
||||||
|
*/
|
||||||
|
function verificationUrl(options: CreateIdentityNotificationsOptions, token: string): string {
|
||||||
|
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_VERIFY_EMAIL_LANDING);
|
||||||
|
return `${options.authBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}&callbackURL=${encodeURIComponent(callback)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Better Auth's reset callback, rebuilt the same way. It validates the token, then redirects the
|
||||||
|
* browser to the Dashboard landing with the token attached. */
|
||||||
|
function resetUrl(options: CreateIdentityNotificationsOptions, token: string): string {
|
||||||
|
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_RESET_PASSWORD_LANDING);
|
||||||
|
return `${options.authBaseUrl}/api/auth/reset-password/${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}?callbackURL=${encodeURIComponent(callback)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escapes the four characters that could otherwise close an element or attribute in the HTML body.
|
||||||
|
* Only the link and the greeting name are interpolated, and the name comes from the user record. */
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same rule `validateMailMessage` applies, expressed the same way it expresses it: a code-point
|
||||||
|
* scan rather than a regular expression, because a regex over a control-character range is itself
|
||||||
|
* something a linter (rightly) objects to.
|
||||||
|
*/
|
||||||
|
function hasControlCharacter(value: string): boolean {
|
||||||
|
for (const character of value) {
|
||||||
|
const codePoint = character.codePointAt(0);
|
||||||
|
if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A greeting that is safe to place in a message body.
|
||||||
|
*
|
||||||
|
* A stored display name is not trusted to be free of control characters — the message validator
|
||||||
|
* would reject the whole message for one stray carriage return, which would turn a cosmetic problem
|
||||||
|
* into an undeliverable password reset. So an unusable name simply falls back to no greeting.
|
||||||
|
*/
|
||||||
|
function greeting(name: string | undefined): string {
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
return "Hello,";
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.length > 64 || hasControlCharacter(trimmed)) {
|
||||||
|
return "Hello,";
|
||||||
|
}
|
||||||
|
return `Hello ${trimmed},`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPIRY_SENTENCE = "This link expires in one hour.";
|
||||||
|
const UNEXPECTED_SENTENCE =
|
||||||
|
"If you did not expect this message, you can ignore it and nothing will change.";
|
||||||
|
|
||||||
|
interface MessageBodies {
|
||||||
|
readonly subject: string;
|
||||||
|
readonly text: string;
|
||||||
|
readonly html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verificationBodies(name: string | undefined, url: string): MessageBodies {
|
||||||
|
const lead = greeting(name);
|
||||||
|
return {
|
||||||
|
subject: "Verify your Ekklesia email address",
|
||||||
|
html: [
|
||||||
|
`<p>${escapeHtml(lead)}</p>`,
|
||||||
|
"<p>Confirm this email address so it can be used to sign in to Ekklesia.</p>",
|
||||||
|
`<p><a href="${escapeHtml(url)}">Verify this email address</a></p>`,
|
||||||
|
`<p>${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}</p>`,
|
||||||
|
].join("\n"),
|
||||||
|
text: [
|
||||||
|
lead,
|
||||||
|
"",
|
||||||
|
"Confirm this email address so it can be used to sign in to Ekklesia:",
|
||||||
|
"",
|
||||||
|
url,
|
||||||
|
"",
|
||||||
|
EXPIRY_SENTENCE,
|
||||||
|
UNEXPECTED_SENTENCE,
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBodies(name: string | undefined, url: string): MessageBodies {
|
||||||
|
const lead = greeting(name);
|
||||||
|
const closing = `${UNEXPECTED_SENTENCE} Your current password stays in place until a new one is chosen.`;
|
||||||
|
return {
|
||||||
|
subject: "Reset your Ekklesia password",
|
||||||
|
html: [
|
||||||
|
`<p>${escapeHtml(lead)}</p>`,
|
||||||
|
"<p>Someone asked to reset the password for this Ekklesia account.</p>",
|
||||||
|
`<p><a href="${escapeHtml(url)}">Choose a new password</a></p>`,
|
||||||
|
`<p>${EXPIRY_SENTENCE} ${escapeHtml(closing)}</p>`,
|
||||||
|
].join("\n"),
|
||||||
|
text: [
|
||||||
|
lead,
|
||||||
|
"",
|
||||||
|
"Someone asked to reset the password for this Ekklesia account. Choose a new one here:",
|
||||||
|
"",
|
||||||
|
url,
|
||||||
|
"",
|
||||||
|
EXPIRY_SENTENCE,
|
||||||
|
closing,
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReporter(failure: IdentityNotificationFailure): void {
|
||||||
|
// One fixed line. The flow and a sanitized code are exactly what an operator needs in order to go
|
||||||
|
// look at `pnpm doctor`; the address, the token, and the link are exactly what must never be here.
|
||||||
|
console.error(`[identity] ${failure.kind} email was not delivered: ${failure.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureReason(error: unknown): string {
|
||||||
|
return error instanceof MailError ? error.code : "MAIL_SEND_FAILED";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the two Better Auth callbacks.
|
||||||
|
*
|
||||||
|
* Neither rejects. A delivery failure is reported through `report` and the callback resolves, so the
|
||||||
|
* HTTP response an unauthenticated caller sees is identical whether the address exists, whether it
|
||||||
|
* was already verified, and whether the relay was reachable.
|
||||||
|
*/
|
||||||
|
export function createIdentityNotifications(
|
||||||
|
options: CreateIdentityNotificationsOptions,
|
||||||
|
): IdentityNotifications {
|
||||||
|
const report = options.report ?? defaultReporter;
|
||||||
|
|
||||||
|
const deliver = async (
|
||||||
|
kind: IdentityNotificationKind,
|
||||||
|
recipient: IdentityNotificationRecipient,
|
||||||
|
bodies: MessageBodies,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!options.mail) {
|
||||||
|
report({ kind, reason: "MAIL_NOT_CONFIGURED" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.mail.send(
|
||||||
|
validateMailMessage({
|
||||||
|
html: bodies.html,
|
||||||
|
subject: bodies.subject,
|
||||||
|
text: bodies.text,
|
||||||
|
to: recipient.email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
report({ kind, reason: failureReason(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifications: IdentityNotifications = {
|
||||||
|
sendResetPassword: async (data) => {
|
||||||
|
await deliver(
|
||||||
|
"password-reset",
|
||||||
|
data.user,
|
||||||
|
resetBodies(data.user.name, resetUrl(options, data.token)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sendVerificationEmail: async (data) => {
|
||||||
|
await deliver(
|
||||||
|
"email-verification",
|
||||||
|
data.user,
|
||||||
|
verificationBodies(data.user.name, verificationUrl(options, data.token)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze(notifications);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies";
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import type { IdentityConfig } from "./config.js";
|
import type { IdentityConfig } from "./config.js";
|
||||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||||
|
import type { IdentityNotifications } from "./notifications.js";
|
||||||
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
||||||
|
|
||||||
export type IdentityRuntimeErrorCode =
|
export type IdentityRuntimeErrorCode =
|
||||||
@@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
|
|||||||
*/
|
*/
|
||||||
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
|
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One hour for both, matching the pinned release's own defaults but stated rather than inherited.
|
||||||
|
*
|
||||||
|
* Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough
|
||||||
|
* that someone who reads their mail after lunch is not sent back to the start. The message bodies
|
||||||
|
* state the same hour, so the two can be checked against each other.
|
||||||
|
*/
|
||||||
|
const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which
|
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which
|
||||||
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
|
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
|
||||||
@@ -82,10 +93,29 @@ export interface CreateIdentityRuntimeOptions {
|
|||||||
readonly config: IdentityConfig;
|
readonly config: IdentityConfig;
|
||||||
readonly db: IdentityDrizzleDatabase;
|
readonly db: IdentityDrizzleDatabase;
|
||||||
/**
|
/**
|
||||||
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test
|
* How verification and password-reset messages are delivered.
|
||||||
* composition that issues many such requests quickly. Rate limiting itself is always enabled;
|
*
|
||||||
* production composition must leave this unset so Better Auth's own strict built-in default
|
* Injected rather than constructed here, for the same reason the database is: this file composes
|
||||||
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified.
|
* Better Auth and must not decide what a message says or how it travels. Omitting it is a
|
||||||
|
* complete, valid deployment — email verification and password recovery are then configured
|
||||||
|
* exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while
|
||||||
|
* every HTTP response keeps its usual shape (ADR 0038).
|
||||||
|
*
|
||||||
|
* Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An
|
||||||
|
* installation with no mail cannot verify a new account, which is why the initial platform owner
|
||||||
|
* is created already verified by a trusted operator command rather than by an email round trip.
|
||||||
|
*/
|
||||||
|
readonly notifications?: IdentityNotifications;
|
||||||
|
/**
|
||||||
|
* A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats
|
||||||
|
* more strictly than its global default, used only by test composition that issues many such
|
||||||
|
* requests quickly. Rate limiting itself is always enabled; production composition must leave this
|
||||||
|
* unset so Better Auth's own built-in defaults apply unmodified — 3 requests / 10 seconds on
|
||||||
|
* sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification
|
||||||
|
* resends, which are the two paths an attacker could otherwise use to send mail repeatedly.
|
||||||
|
*
|
||||||
|
* That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single
|
||||||
|
* deployment and is deliberately **not** claimed to be a distributed one.
|
||||||
*/
|
*/
|
||||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
}
|
}
|
||||||
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
|
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
|
||||||
// in the pinned release): an implicit default can change silently on a future Better Auth
|
// in the pinned release): an implicit default can change silently on a future Better Auth
|
||||||
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary.
|
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint
|
||||||
|
// enforces the identical pair from the same configuration, so there is deliberately no
|
||||||
|
// separate reset-time password policy.
|
||||||
maxPasswordLength: 128,
|
maxPasswordLength: 128,
|
||||||
minPasswordLength: 8,
|
minPasswordLength: 8,
|
||||||
|
/**
|
||||||
|
* A person may not hold a session until their address is verified (ADR 0038).
|
||||||
|
*
|
||||||
|
* In the pinned release this is enforced *after* the password is checked and *before* any
|
||||||
|
* session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set.
|
||||||
|
* That is the whole reason no "unverified principal" exists anywhere in this platform — a
|
||||||
|
* correct password on an unverified account produces no session at all, so there is no
|
||||||
|
* half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to
|
||||||
|
* describe only anonymous, authenticated human, and device.
|
||||||
|
*/
|
||||||
|
requireEmailVerification: true,
|
||||||
|
/** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the
|
||||||
|
* password bounds are pinned. */
|
||||||
|
resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS,
|
||||||
|
/**
|
||||||
|
* Every existing session for that account is deleted when its password is reset.
|
||||||
|
*
|
||||||
|
* Password reset is the recovery path someone uses precisely when they suspect their
|
||||||
|
* credential is compromised, and leaving the attacker's session alive would make the recovery
|
||||||
|
* cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned
|
||||||
|
* release), not a Core reach into the session table.
|
||||||
|
*
|
||||||
|
* It deletes *all* of that human's sessions, including the browser that performed the reset —
|
||||||
|
* so the completion state returns to the sign-in form rather than pretending to be signed in.
|
||||||
|
* Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no
|
||||||
|
* password, and are entirely unaffected (ADR 0036).
|
||||||
|
*/
|
||||||
|
revokeSessionsOnPasswordReset: true,
|
||||||
|
...(options.notifications
|
||||||
|
? { sendResetPassword: options.notifications.sendResetPassword }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Verification is configured identically whether or not mail is deliverable, so the policy an
|
||||||
|
* installation enforces never depends on the state of its relay.
|
||||||
|
*/
|
||||||
|
emailVerification: {
|
||||||
|
/**
|
||||||
|
* No session is minted from following an emailed link.
|
||||||
|
*
|
||||||
|
* A verification URL travels through mail infrastructure and is routinely fetched by things
|
||||||
|
* that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning
|
||||||
|
* that GET into a logged-in browser session would hand a session to whoever, or whatever,
|
||||||
|
* dereferenced the link. Verification therefore only flips the flag, and the person signs in
|
||||||
|
* afterwards through the ordinary form.
|
||||||
|
*/
|
||||||
|
autoSignInAfterVerification: false,
|
||||||
|
/** Pinned rather than left on the release's `|| 3600` fallback. */
|
||||||
|
expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS,
|
||||||
|
/**
|
||||||
|
* A sign-in attempt against an unverified account re-sends the verification message.
|
||||||
|
*
|
||||||
|
* This is the resend path that costs nothing to secure: reaching it requires the correct
|
||||||
|
* password, so it reveals nothing an attacker did not already know, and it means the common
|
||||||
|
* case — the first link expired — is handled by the thing the person was already trying to do.
|
||||||
|
* The standalone `/send-verification-email` endpoint remains available for the case where they
|
||||||
|
* would rather not retype a password, and is enumeration-safe on its own.
|
||||||
|
*/
|
||||||
|
sendOnSignIn: true,
|
||||||
|
/**
|
||||||
|
* Pinned rather than left on the release's "follow `requireEmailVerification`" fallback.
|
||||||
|
*
|
||||||
|
* It reaches nothing in production, where signup is refused outright — but stating it means a
|
||||||
|
* development or test deployment that does enable signup behaves the way this platform
|
||||||
|
* intends, rather than the way an inherited default happens to.
|
||||||
|
*/
|
||||||
|
sendOnSignUp: true,
|
||||||
|
...(options.notifications
|
||||||
|
? { sendVerificationEmail: options.notifications.sendVerificationEmail }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
logger: {
|
logger: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
|
|||||||
...(rateLimitOverride
|
...(rateLimitOverride
|
||||||
? {
|
? {
|
||||||
customRules: {
|
customRules: {
|
||||||
|
// The four paths Better Auth applies a stricter-than-global default to. Production
|
||||||
|
// composition never sets this override, so those defaults — 3 requests / 10 seconds
|
||||||
|
// on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an
|
||||||
|
// email — remain in force there, unmodified.
|
||||||
|
"/request-password-reset": {
|
||||||
|
max: rateLimitOverride.max ?? 100,
|
||||||
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
},
|
||||||
|
"/send-verification-email": {
|
||||||
|
max: rateLimitOverride.max ?? 100,
|
||||||
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
},
|
||||||
"/sign-in/email": {
|
"/sign-in/email": {
|
||||||
max: rateLimitOverride.max ?? 100,
|
max: rateLimitOverride.max ?? 100,
|
||||||
window: rateLimitOverride.window ?? 10,
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
|||||||
+61
-1
@@ -8,6 +8,7 @@ export type MailConfigErrorCode =
|
|||||||
| "MAIL_FROM_ADDRESS_INVALID"
|
| "MAIL_FROM_ADDRESS_INVALID"
|
||||||
| "MAIL_FROM_ADDRESS_MISSING"
|
| "MAIL_FROM_ADDRESS_MISSING"
|
||||||
| "MAIL_FROM_NAME_INVALID"
|
| "MAIL_FROM_NAME_INVALID"
|
||||||
|
| "MAIL_HELO_NAME_INVALID"
|
||||||
| "MAIL_HOST_INVALID"
|
| "MAIL_HOST_INVALID"
|
||||||
| "MAIL_PASSWORD_WITHOUT_USERNAME"
|
| "MAIL_PASSWORD_WITHOUT_USERNAME"
|
||||||
| "MAIL_PORT_INVALID"
|
| "MAIL_PORT_INVALID"
|
||||||
@@ -19,6 +20,8 @@ const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object
|
|||||||
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
|
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
|
||||||
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
|
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
|
||||||
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
|
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
|
||||||
|
MAIL_HELO_NAME_INVALID:
|
||||||
|
"The SMTP HELO name must be a fully qualified domain name, with no scheme, port, brackets, or spaces.",
|
||||||
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
|
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
|
||||||
MAIL_PASSWORD_WITHOUT_USERNAME:
|
MAIL_PASSWORD_WITHOUT_USERNAME:
|
||||||
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
|
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
|
||||||
@@ -70,6 +73,7 @@ export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
|
|||||||
|
|
||||||
export interface MailProviderConfigInput {
|
export interface MailProviderConfigInput {
|
||||||
readonly host?: string | undefined;
|
readonly host?: string | undefined;
|
||||||
|
readonly heloName?: string | undefined;
|
||||||
readonly port?: string | number | undefined;
|
readonly port?: string | number | undefined;
|
||||||
readonly security?: string | undefined;
|
readonly security?: string | undefined;
|
||||||
readonly username?: string | undefined;
|
readonly username?: string | undefined;
|
||||||
@@ -87,6 +91,24 @@ export interface MailProviderConfigInput {
|
|||||||
*/
|
*/
|
||||||
export interface MailProviderConfig {
|
export interface MailProviderConfig {
|
||||||
readonly host: string;
|
readonly host: string;
|
||||||
|
/**
|
||||||
|
* The name this client identifies itself as in the SMTP `EHLO` command, when the deployment sets
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* This exists because of a real failure, and the failure is generic rather than tied to any one
|
||||||
|
* provider. An SMTP client must greet the server with its own identity, and when nothing is
|
||||||
|
* configured Nodemailer derives it from the machine's hostname — falling back to the address
|
||||||
|
* literal `[127.0.0.1]` whenever that hostname is not fully qualified, which is the normal state
|
||||||
|
* of a container or a VM nobody has named. Some relays refuse that greeting outright, closing the
|
||||||
|
* connection with a `421` before any message is offered, which surfaces here as a bare
|
||||||
|
* `MAIL_PROVIDER_UNAVAILABLE` and looks indistinguishable from the relay being down.
|
||||||
|
*
|
||||||
|
* Absent means Nodemailer's own derivation is used, unchanged. It is deliberately not defaulted to
|
||||||
|
* the sender's domain: a host whose machine hostname *is* a valid FQDN may already be greeting
|
||||||
|
* correctly — some relays check it against the connecting address's reverse DNS — and silently
|
||||||
|
* replacing a working identity would trade one deployment's bug for another's.
|
||||||
|
*/
|
||||||
|
readonly heloName?: string;
|
||||||
readonly port: number;
|
readonly port: number;
|
||||||
readonly security: MailSecurityMode;
|
readonly security: MailSecurityMode;
|
||||||
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
|
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
|
||||||
@@ -119,6 +141,29 @@ function validateHost(host: string): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fully qualified domain name, and deliberately nothing else.
|
||||||
|
*
|
||||||
|
* Stricter than `HOST_PATTERN` on purpose, in two ways. It requires **at least one dot**, because a
|
||||||
|
* single unqualified label is precisely the input Nodemailer already refuses to use — configuring
|
||||||
|
* one would reproduce the failure this setting exists to fix. And it requires the final label to
|
||||||
|
* begin with a letter, which rejects a bare IPv4 address: `EHLO 192.0.2.1` is not valid SMTP syntax,
|
||||||
|
* and the bracketed address-literal form that *is* valid is the greeting the affected relays reject.
|
||||||
|
*
|
||||||
|
* Everything the requirement rules out falls out of the anchors and the character class rather than
|
||||||
|
* from a list of special cases: a scheme, a port, a path, whitespace, brackets, a comma, a
|
||||||
|
* carriage return or line feed, and any other prose all fail to match.
|
||||||
|
*/
|
||||||
|
const HELO_NAME_PATTERN =
|
||||||
|
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
|
||||||
|
|
||||||
|
function validateHeloName(heloName: string): string {
|
||||||
|
if (!HELO_NAME_PATTERN.test(heloName)) {
|
||||||
|
throw new MailConfigError("MAIL_HELO_NAME_INVALID");
|
||||||
|
}
|
||||||
|
return heloName;
|
||||||
|
}
|
||||||
|
|
||||||
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
|
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
|
||||||
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
|
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
|
||||||
return DEFAULT_PORTS[security];
|
return DEFAULT_PORTS[security];
|
||||||
@@ -174,6 +219,16 @@ export function createMailProviderConfig(
|
|||||||
const validatedHost = validateHost(host);
|
const validatedHost = validateHost(host);
|
||||||
const port = validatePort(input?.port, security);
|
const port = validatePort(input?.port, security);
|
||||||
|
|
||||||
|
// Validated exactly as supplied, with no trimming. Every other value here tolerates surrounding
|
||||||
|
// whitespace as a copy-paste artefact; this one must not, because a stray character in the value
|
||||||
|
// goes straight onto the wire in a protocol command. It is refused rather than repaired, the same
|
||||||
|
// rule an address follows.
|
||||||
|
const heloNameInput = input?.heloName;
|
||||||
|
const heloName =
|
||||||
|
heloNameInput === undefined || heloNameInput === ""
|
||||||
|
? undefined
|
||||||
|
: validateHeloName(heloNameInput);
|
||||||
|
|
||||||
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
|
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
|
||||||
const password = input?.password === "" ? undefined : input?.password;
|
const password = input?.password === "" ? undefined : input?.password;
|
||||||
if (username !== undefined && password === undefined) {
|
if (username !== undefined && password === undefined) {
|
||||||
@@ -201,6 +256,7 @@ export function createMailProviderConfig(
|
|||||||
host: validatedHost,
|
host: validatedHost,
|
||||||
port,
|
port,
|
||||||
security,
|
security,
|
||||||
|
...(heloName === undefined ? {} : { heloName }),
|
||||||
...(fromName === undefined ? {} : { fromName }),
|
...(fromName === undefined ? {} : { fromName }),
|
||||||
...(username === undefined || password === undefined ? {} : { password, username }),
|
...(username === undefined || password === undefined ? {} : { password, username }),
|
||||||
});
|
});
|
||||||
@@ -219,7 +275,11 @@ export function describeMailProvider(config: MailProviderConfig): string {
|
|||||||
config.fromName === undefined
|
config.fromName === undefined
|
||||||
? config.fromAddress
|
? config.fromAddress
|
||||||
: `${config.fromName} <${config.fromAddress}>`;
|
: `${config.fromName} <${config.fromAddress}>`;
|
||||||
|
// The HELO name is host infrastructure, not a credential: it is the identity this client announces
|
||||||
|
// in the clear on every connection, so printing it costs nothing and is exactly what an operator
|
||||||
|
// needs when a relay has refused the greeting.
|
||||||
|
const helo = config.heloName === undefined ? "" : ` · HELO ${config.heloName}`;
|
||||||
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
|
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
|
||||||
config.username === undefined ? "unauthenticated relay" : "authenticated"
|
config.username === undefined ? "unauthenticated relay" : "authenticated"
|
||||||
}`;
|
}${helo}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ function transportOptions(
|
|||||||
disableUrlAccess: true,
|
disableUrlAccess: true,
|
||||||
greetingTimeout: bounds.greetingTimeoutMs,
|
greetingTimeout: bounds.greetingTimeoutMs,
|
||||||
host: config.host,
|
host: config.host,
|
||||||
|
// The identity this client announces in `EHLO`. Left to Nodemailer when the deployment
|
||||||
|
// configures none — it derives one from the machine hostname, falling back to the address
|
||||||
|
// literal `[127.0.0.1]` when that hostname is not fully qualified, which some relays refuse
|
||||||
|
// with a `421` before a message is ever offered (ADR 0037).
|
||||||
|
...(config.heloName === undefined ? {} : { name: config.heloName }),
|
||||||
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
|
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
|
||||||
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
|
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
|
||||||
// platform sending a handful of transactional messages buys nothing and makes the failure modes
|
// platform sending a handful of transactional messages buys nothing and makes the failure modes
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue {
|
|||||||
readonly path?: string;
|
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 {
|
export interface ResolvedModuleEntrypoints {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly schema?: string;
|
readonly schema?: string;
|
||||||
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
|||||||
|
|
||||||
export interface DiscoveredModule {
|
export interface DiscoveredModule {
|
||||||
readonly directory: string;
|
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 entrypoints: ResolvedModuleEntrypoints;
|
||||||
readonly manifest: ReadonlyModuleManifest;
|
readonly manifest: ReadonlyModuleManifest;
|
||||||
readonly manifestPath: string;
|
readonly manifestPath: string;
|
||||||
@@ -110,6 +126,7 @@ interface ModulePackageData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ModuleCandidate {
|
interface ModuleCandidate {
|
||||||
|
contributions: readonly ResolvedModuleContribution[];
|
||||||
directory: string;
|
directory: string;
|
||||||
directoryName: string;
|
directoryName: string;
|
||||||
entrypoints: ResolvedModuleEntrypoints;
|
entrypoints: ResolvedModuleEntrypoints;
|
||||||
@@ -257,13 +274,19 @@ function validatePackageData(
|
|||||||
return { name, version };
|
return { name, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedModuleArtifacts {
|
||||||
|
readonly contributions: readonly ResolvedModuleContribution[];
|
||||||
|
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveEntrypoints(
|
async function resolveEntrypoints(
|
||||||
moduleDirectory: string,
|
moduleDirectory: string,
|
||||||
directoryName: string,
|
directoryName: string,
|
||||||
manifest: ModuleManifest,
|
manifest: ModuleManifest,
|
||||||
issues: ModuleDiscoveryIssue[],
|
issues: ModuleDiscoveryIssue[],
|
||||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
): Promise<ResolvedModuleArtifacts | undefined> {
|
||||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||||
|
const resolvedContributions = new Map<string, string>();
|
||||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||||
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||||
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||||
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
|
|||||||
issuePath: `entrypoints.${entrypointName}`,
|
issuePath: `entrypoints.${entrypointName}`,
|
||||||
relativeEntrypoint,
|
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 = [
|
const configuredEntrypoints = [
|
||||||
...ordinaryConfiguredEntrypoints,
|
...ordinaryConfiguredEntrypoints,
|
||||||
...(display === undefined
|
...(display === undefined
|
||||||
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
|
|||||||
relativeEntrypoint: display.artifact,
|
relativeEntrypoint: display.artifact,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
...configuredContributions,
|
||||||
];
|
];
|
||||||
let entrypointCollision = false;
|
let entrypointCollision = false;
|
||||||
|
|
||||||
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entrypointName.startsWith("contribution:")) {
|
||||||
|
resolvedContributions.set(
|
||||||
|
entrypointName.slice("contribution:".length),
|
||||||
|
canonicalEntrypoint,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = errorCode(error);
|
const code = errorCode(error);
|
||||||
issues.push({
|
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 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,
|
server: resolved.server,
|
||||||
...(resolved.schema ? { schema: resolved.schema } : {}),
|
...(resolved.schema ? { schema: resolved.schema } : {}),
|
||||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||||
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { contributions: Object.freeze(contributions), entrypoints };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanModuleDirectory(
|
async function scanModuleDirectory(
|
||||||
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||||
if (!entrypoints) {
|
if (!artifacts) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
contributions: artifacts.contributions,
|
||||||
directory: canonicalModuleDirectory,
|
directory: canonicalModuleDirectory,
|
||||||
directoryName,
|
directoryName,
|
||||||
entrypoints,
|
entrypoints: artifacts.entrypoints,
|
||||||
manifest,
|
manifest,
|
||||||
manifestPath: canonicalManifestPath,
|
manifestPath: canonicalManifestPath,
|
||||||
packageName: packageData.name,
|
packageName: packageData.name,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode =
|
|||||||
| "MODULE_ROLLBACK_TIMEOUT"
|
| "MODULE_ROLLBACK_TIMEOUT"
|
||||||
| "MODULE_ROUTE_DEFINITION_INVALID"
|
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||||
| "MODULE_ROUTE_DUPLICATE"
|
| "MODULE_ROUTE_DUPLICATE"
|
||||||
|
| "MODULE_ROUTE_BODY_MODE_INVALID"
|
||||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||||
| "MODULE_ROUTE_METHOD_INVALID"
|
| "MODULE_ROUTE_METHOD_INVALID"
|
||||||
| "MODULE_ROUTE_OVERLAP"
|
| "MODULE_ROUTE_OVERLAP"
|
||||||
@@ -467,6 +468,7 @@ class StagedModuleHttpRoutes {
|
|||||||
shapes.add(shapeKey);
|
shapes.add(shapeKey);
|
||||||
local.push(
|
local.push(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
|
bodyMode: parsed.data.bodyMode,
|
||||||
handler: parsed.data.handler,
|
handler: parsed.data.handler,
|
||||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||||
method: parsed.data.method,
|
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";
|
import type { DiscoveredModule } from "./discovery.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
|
|||||||
readonly permissions: readonly PermissionDeclaration[];
|
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?"
|
* 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.
|
* a display merely because a caller appends `/display` to its publication URL.
|
||||||
*/
|
*/
|
||||||
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
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 {
|
): ModuleUiPublicationRegistry {
|
||||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||||
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
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) {
|
for (const module of modules) {
|
||||||
const artifactPath = module.entrypoints.ui;
|
const artifactPath = module.entrypoints.ui;
|
||||||
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
|
|||||||
|
|
||||||
const published = Object.freeze([...byModuleId.values()]);
|
const published = Object.freeze([...byModuleId.values()]);
|
||||||
const publishedDisplays = Object.freeze([...displaysByModuleId.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({
|
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,
|
list: (): readonly PublishableModuleUi[] => published,
|
||||||
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function createModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
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" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({
|
|||||||
|
|
||||||
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/unpublished/${id}`,
|
directory: `/unpublished/${id}`,
|
||||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
@@ -363,6 +364,36 @@ describe("Doctor host-configuration parity — outbound email regression", () =>
|
|||||||
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("probes with the SMTP client identity the runtime would use, and reports it", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "mail.example.org" }),
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
// Doctor builds its provider from the same validated configuration `startApi` does, so a relay
|
||||||
|
// that refuses the greeting fails here rather than only when a reset email silently never
|
||||||
|
// arrives. The name is announced in the clear on every connection, so showing it leaks nothing.
|
||||||
|
expect(services.probeMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ heloName: "mail.example.org" }),
|
||||||
|
);
|
||||||
|
expect(mailCheck.details[0]).toContain("HELO mail.example.org");
|
||||||
|
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a malformed SMTP client identity as a typed failure, naming no value", async () => {
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "smtp://internal-host:2525" }),
|
||||||
|
);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
expect(mailCheck.status).toBe("failure");
|
||||||
|
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_HELO_NAME_INVALID.");
|
||||||
|
expect(JSON.stringify(mailCheck)).not.toContain("internal-host");
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => {
|
it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => {
|
||||||
const { report } = await diagnose(
|
const { report } = await diagnose(
|
||||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }),
|
environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }),
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ Object storage
|
|||||||
|
|
||||||
Outbound email
|
Outbound email
|
||||||
○ Outbound email not configured
|
○ Outbound email not configured
|
||||||
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
|
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
|
||||||
|
|
||||||
Result: READY WITH WARNINGS`);
|
Result: READY WITH WARNINGS`);
|
||||||
});
|
});
|
||||||
|
|||||||
+172
-1
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
|
|||||||
|
|
||||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/unpublished/${id}`,
|
directory: `/unpublished/${id}`,
|
||||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
@@ -704,6 +705,88 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Dashboard and API topology (ADR 0045)", () => {
|
||||||
|
const secret = "correct-horse-battery-staple-0123456789";
|
||||||
|
async function identityCheck(identity: {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly dashboardOrigin?: string;
|
||||||
|
readonly environment?: "development" | "production";
|
||||||
|
readonly trustedOrigins: readonly string[];
|
||||||
|
}) {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{
|
||||||
|
...options(),
|
||||||
|
identityConfiguration: { environment: "production", secret, ...identity },
|
||||||
|
},
|
||||||
|
dependencies(),
|
||||||
|
);
|
||||||
|
return { check: check(report, "identity.configuration"), report };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports the same-origin topology when no Dashboard origin is set", async () => {
|
||||||
|
const { check: identity } = await identityCheck({
|
||||||
|
baseUrl: "https://ekklesia.example.test",
|
||||||
|
trustedOrigins: ["https://ekklesia.example.test"],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("pass");
|
||||||
|
expect(identity.details).toContain(
|
||||||
|
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the split topology for sibling hostnames without printing either", async () => {
|
||||||
|
const { check: identity, report } = await identityCheck({
|
||||||
|
baseUrl: "https://api.example.test",
|
||||||
|
dashboardOrigin: "https://dash.example.test",
|
||||||
|
trustedOrigins: ["https://dash.example.test"],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("pass");
|
||||||
|
expect(identity.details).toContain(
|
||||||
|
"Topology: split (the Dashboard and the API are on separate origins).",
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(report)).not.toContain("dash.example.test");
|
||||||
|
expect(JSON.stringify(report)).not.toContain("api.example.test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["different registrable domains", "https://dash.example.test", "https://api.example.org"],
|
||||||
|
["two IP addresses", "https://10.0.0.2", "https://10.0.0.1"],
|
||||||
|
["a parent that is only a top-level domain", "https://dash.test", "https://api.test"],
|
||||||
|
])("warns, without failing, when split hostnames may not share a site: %s", async (_case, dashboardOrigin, baseUrl) => {
|
||||||
|
const { check: identity, report } = await identityCheck({
|
||||||
|
baseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
trustedOrigins: [dashboardOrigin],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("warning");
|
||||||
|
expect(identity.details.join("\n")).toContain("not siblings under one parent domain");
|
||||||
|
expect(doctorExitCode(report)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats another port on the same host as the same site (split development)", async () => {
|
||||||
|
const { check: identity } = await identityCheck({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
dashboardOrigin: "http://localhost:5173",
|
||||||
|
environment: "development",
|
||||||
|
trustedOrigins: ["http://localhost:5173"],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("pass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails, as the API's startup would, for a Dashboard origin the API does not trust", async () => {
|
||||||
|
const { check: identity, report } = await identityCheck({
|
||||||
|
baseUrl: "https://api.example.test",
|
||||||
|
dashboardOrigin: "https://dash.example.test",
|
||||||
|
trustedOrigins: ["https://other.example.test"],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("failure");
|
||||||
|
expect(identity.details.some((detail) => detail.includes("DASHBOARD_ORIGIN_UNTRUSTED"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(doctorExitCode(report)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
||||||
const hostileSecret = "s3cr3t-do-not-log";
|
const hostileSecret = "s3cr3t-do-not-log";
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
@@ -1471,3 +1554,91 @@ describe("Ekklesia Doctor — object storage", () => {
|
|||||||
expect(check(report, "object-storage.bindings").status).toBe("skipped");
|
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",
|
"id": "db-facade",
|
||||||
"name": "Database Facade Integration Test Module",
|
"name": "Database Facade Integration Test Module",
|
||||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
|||||||
"EKKLESIA_CLIENT_ADDRESS_HEADER",
|
"EKKLESIA_CLIENT_ADDRESS_HEADER",
|
||||||
"EKKLESIA_CLIENT_ADDRESS_MODE",
|
"EKKLESIA_CLIENT_ADDRESS_MODE",
|
||||||
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
|
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
|
||||||
|
"EKKLESIA_DASHBOARD_ORIGIN",
|
||||||
"EKKLESIA_DATABASE_CA",
|
"EKKLESIA_DATABASE_CA",
|
||||||
"EKKLESIA_DATABASE_TLS_MODE",
|
"EKKLESIA_DATABASE_TLS_MODE",
|
||||||
"EKKLESIA_IDENTITY_BASE_URL",
|
"EKKLESIA_IDENTITY_BASE_URL",
|
||||||
@@ -70,6 +71,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
|||||||
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
||||||
"EKKLESIA_SMTP_FROM_ADDRESS",
|
"EKKLESIA_SMTP_FROM_ADDRESS",
|
||||||
"EKKLESIA_SMTP_FROM_NAME",
|
"EKKLESIA_SMTP_FROM_NAME",
|
||||||
|
"EKKLESIA_SMTP_HELO_NAME",
|
||||||
"EKKLESIA_SMTP_HOST",
|
"EKKLESIA_SMTP_HOST",
|
||||||
"EKKLESIA_SMTP_PASSWORD",
|
"EKKLESIA_SMTP_PASSWORD",
|
||||||
"EKKLESIA_SMTP_PORT",
|
"EKKLESIA_SMTP_PORT",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const productionEnvironment = Object.freeze({
|
|||||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
||||||
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||||
EKKLESIA_SMTP_FROM_NAME: "Ekklesia",
|
EKKLESIA_SMTP_FROM_NAME: "Ekklesia",
|
||||||
|
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||||
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||||
EKKLESIA_SMTP_PASSWORD: "sup3r-secret-relay-password",
|
EKKLESIA_SMTP_PASSWORD: "sup3r-secret-relay-password",
|
||||||
EKKLESIA_SMTP_PORT: "587",
|
EKKLESIA_SMTP_PORT: "587",
|
||||||
@@ -67,6 +68,7 @@ describe("host configuration loading", () => {
|
|||||||
expect(host.mail).toEqual({
|
expect(host.mail).toEqual({
|
||||||
fromAddress: "[email protected]",
|
fromAddress: "[email protected]",
|
||||||
fromName: "Ekklesia",
|
fromName: "Ekklesia",
|
||||||
|
heloName: "mail.example.org",
|
||||||
host: "smtp.example.org",
|
host: "smtp.example.org",
|
||||||
password: "sup3r-secret-relay-password",
|
password: "sup3r-secret-relay-password",
|
||||||
port: "587",
|
port: "587",
|
||||||
@@ -80,6 +82,9 @@ describe("host configuration loading", () => {
|
|||||||
});
|
});
|
||||||
expect(relayByAddress.mail.username).toBeUndefined();
|
expect(relayByAddress.mail.username).toBeUndefined();
|
||||||
expect(relayByAddress.mail.password).toBeUndefined();
|
expect(relayByAddress.mail.password).toBeUndefined();
|
||||||
|
// The SMTP client identity is optional too, and unset is the ordinary case: it matters only
|
||||||
|
// where the machine's own hostname is not a fully qualified name the relay accepts.
|
||||||
|
expect(relayByAddress.mail.heloName).toBeUndefined();
|
||||||
// A source-IP-authorized relay has no credentials at all, and that must still validate.
|
// A source-IP-authorized relay has no credentials at all, and that must still validate.
|
||||||
expect(createMailProviderConfig(relayByAddress.mail)).toBeDefined();
|
expect(createMailProviderConfig(relayByAddress.mail)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ describe("Core identity configuration", () => {
|
|||||||
|
|
||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
baseUrl: validBaseUrl,
|
baseUrl: validBaseUrl,
|
||||||
|
// Unset: the Dashboard is served from the identity base URL, which is the ordinary
|
||||||
|
// same-origin production shape (ADR 0038).
|
||||||
|
dashboardOrigin: validBaseUrl,
|
||||||
publicSignupEnabled: false,
|
publicSignupEnabled: false,
|
||||||
secret: validSecret,
|
secret: validSecret,
|
||||||
trustedOrigins: validTrustedOrigins,
|
trustedOrigins: validTrustedOrigins,
|
||||||
@@ -419,3 +422,108 @@ describe("Core identity configuration", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Core identity Dashboard origin", () => {
|
||||||
|
/**
|
||||||
|
* Where an emailed verification or reset link sends a browser (ADR 0038).
|
||||||
|
*
|
||||||
|
* The rules exist because a link in an email is the one artifact nobody inspects before clicking:
|
||||||
|
* it must land on the deployment's own Dashboard, over https in production, at an origin Better
|
||||||
|
* Auth will actually accept as a redirect target — and a misconfiguration must fail at startup
|
||||||
|
* rather than produce a link every recipient is refused at.
|
||||||
|
*/
|
||||||
|
it("defaults to the identity base URL, the ordinary same-origin production shape", () => {
|
||||||
|
expect(createIdentityConfig(validInput()).dashboardOrigin).toBe(validBaseUrl);
|
||||||
|
expect(createIdentityConfig({ ...validInput(), dashboardOrigin: "" }).dashboardOrigin).toBe(
|
||||||
|
validBaseUrl,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a separate Dashboard origin that is also a trusted origin", () => {
|
||||||
|
const config = createIdentityConfig({
|
||||||
|
...validInput(),
|
||||||
|
dashboardOrigin: "https://dashboard.example.test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.dashboardOrigin).toBe("https://dashboard.example.test");
|
||||||
|
// Separate from the API origin, deliberately: the two are different concepts and a deployment
|
||||||
|
// may split them.
|
||||||
|
expect(config.dashboardOrigin).not.toBe(config.baseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an origin Better Auth would not accept as a redirect target", () => {
|
||||||
|
// The failure this prevents is silent and total: Better Auth validates the emailed callback
|
||||||
|
// against its trusted origins, so an untrusted Dashboard origin breaks every link it sends.
|
||||||
|
try {
|
||||||
|
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://elsewhere.example.test" });
|
||||||
|
throw new Error("expected the untrusted Dashboard origin to be refused");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_UNTRUSTED" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses anything that is not a canonical origin", () => {
|
||||||
|
for (const value of [
|
||||||
|
"dashboard.example.test",
|
||||||
|
"https://dashboard.example.test/",
|
||||||
|
"https://dashboard.example.test/dashboard",
|
||||||
|
"https://user:[email protected]",
|
||||||
|
"https://dashboard.example.test?a=b",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"//dashboard.example.test",
|
||||||
|
" https://dashboard.example.test",
|
||||||
|
42,
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
createIdentityConfig({ ...validInput(), dashboardOrigin: value });
|
||||||
|
throw new Error(`expected ${String(value)} to be refused`);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INVALID" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires https and a non-loopback host in production, and relaxes both in development", () => {
|
||||||
|
try {
|
||||||
|
createIdentityConfig(
|
||||||
|
// Structural and policy validity are checked before membership, so a plaintext origin is
|
||||||
|
// reported as plaintext rather than as "untrusted" — which would send an operator looking
|
||||||
|
// in the wrong place.
|
||||||
|
{ ...validInput(), dashboardOrigin: "http://dashboard.example.test" },
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
throw new Error("expected plaintext to be refused in production");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
createIdentityConfig(
|
||||||
|
{ ...validInput(), dashboardOrigin: "https://localhost" },
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
throw new Error("expected loopback to be refused in production");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const development = createIdentityConfig(
|
||||||
|
{
|
||||||
|
...developmentInput(),
|
||||||
|
dashboardOrigin: "http://localhost:5173",
|
||||||
|
trustedOrigins: ["http://localhost:3000", "http://localhost:5173"],
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
expect(development.dashboardOrigin).toBe("http://localhost:5173");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never carries the offending origin in the thrown error", () => {
|
||||||
|
try {
|
||||||
|
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://evil.example.test" });
|
||||||
|
throw new Error("expected a refusal");
|
||||||
|
} catch (error) {
|
||||||
|
expect(JSON.stringify({ message: (error as Error).message })).not.toContain("evil");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createIdentityNotifications,
|
||||||
|
type IdentityNotificationFailure,
|
||||||
|
} from "../src/identity/notifications.js";
|
||||||
|
import { MailError, type MailProvider, type ValidatedMailMessage } from "../src/mail/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity feature's two outbound messages (ADR 0038).
|
||||||
|
*
|
||||||
|
* The properties that matter here are all negative ones: that a link is built from configuration
|
||||||
|
* rather than from anything a request supplied, that a token never reaches a log, that a delivery
|
||||||
|
* failure never becomes an observable difference between "this address has an account" and "it does
|
||||||
|
* not", and that identity code learns nothing about SMTP.
|
||||||
|
*/
|
||||||
|
const authBaseUrl = "https://api.example.test";
|
||||||
|
const dashboardOrigin = "https://dashboard.example.test";
|
||||||
|
const token = "verification-token-abc123";
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
readonly sent: ValidatedMailMessage[];
|
||||||
|
readonly failures: IdentityNotificationFailure[];
|
||||||
|
readonly notifications: ReturnType<typeof createIdentityNotifications>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(
|
||||||
|
options: { readonly mail?: MailProvider | undefined; readonly configured?: boolean } = {},
|
||||||
|
): Harness {
|
||||||
|
const sent: ValidatedMailMessage[] = [];
|
||||||
|
const failures: IdentityNotificationFailure[] = [];
|
||||||
|
const provider: MailProvider = options.mail ?? {
|
||||||
|
close: async () => undefined,
|
||||||
|
send: async (message) => {
|
||||||
|
sent.push(message);
|
||||||
|
return { messageId: "provider-message-id" };
|
||||||
|
},
|
||||||
|
verify: async () => undefined,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
failures,
|
||||||
|
notifications: createIdentityNotifications({
|
||||||
|
authBaseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
mail: options.configured === false ? undefined : provider,
|
||||||
|
report: (failure) => failures.push(failure),
|
||||||
|
}),
|
||||||
|
sent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function only(sent: readonly ValidatedMailMessage[]): ValidatedMailMessage {
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
return sent[0] as ValidatedMailMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both bodies, so an assertion can never pass because it only looked at the plaintext one. */
|
||||||
|
function bodies(message: ValidatedMailMessage): string {
|
||||||
|
return `${message.text ?? ""}\n${message.html ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("identity verification email", () => {
|
||||||
|
it("sends one message to the account's own address, with both bodies", async () => {
|
||||||
|
const test = harness();
|
||||||
|
|
||||||
|
await test.notifications.sendVerificationEmail({
|
||||||
|
token,
|
||||||
|
user: { email: "[email protected]", name: "Sam" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = only(test.sent);
|
||||||
|
expect(message.to).toEqual(["[email protected]"]);
|
||||||
|
expect(message.subject).toBe("Verify your Ekklesia email address");
|
||||||
|
expect(message.text).toContain("Hello Sam,");
|
||||||
|
expect(message.html).toContain("<p>Hello Sam,</p>");
|
||||||
|
expect(bodies(message)).toContain("This link expires in one hour.");
|
||||||
|
expect(bodies(message)).toContain("you can ignore it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the link from configuration alone, on the Dashboard origin", async () => {
|
||||||
|
const test = harness();
|
||||||
|
|
||||||
|
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
|
||||||
|
|
||||||
|
const text = only(test.sent).text ?? "";
|
||||||
|
const link = text.split("\n").find((line) => line.startsWith("https://")) ?? "";
|
||||||
|
const url = new URL(link);
|
||||||
|
// Better Auth's own endpoint, so Better Auth remains the only thing that acts on the token.
|
||||||
|
expect(url.origin).toBe(authBaseUrl);
|
||||||
|
expect(url.pathname).toBe("/api/auth/verify-email");
|
||||||
|
expect(url.searchParams.get("token")).toBe(token);
|
||||||
|
// ...and the destination it will redirect to is the Dashboard, never the API origin.
|
||||||
|
const callback = new URL(url.searchParams.get("callbackURL") ?? "");
|
||||||
|
expect(callback.origin).toBe(dashboardOrigin);
|
||||||
|
expect(callback.searchParams.get("ekklesia_auth")).toBe("verify-email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never names the sender, the relay, or any transport detail", async () => {
|
||||||
|
const test = harness();
|
||||||
|
|
||||||
|
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
|
||||||
|
|
||||||
|
// The message contract has no `from` field at all, so identity code cannot choose a sender.
|
||||||
|
expect(Object.keys(only(test.sent))).not.toContain("from");
|
||||||
|
const serialized = JSON.stringify(only(test.sent)).toLowerCase();
|
||||||
|
for (const forbidden of ["smtp", "starttls", "password", "587", "465"]) {
|
||||||
|
expect(serialized).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("identity password-reset email", () => {
|
||||||
|
it("sends one message with a link on Better Auth's reset callback", async () => {
|
||||||
|
const test = harness();
|
||||||
|
|
||||||
|
await test.notifications.sendResetPassword({
|
||||||
|
token: "reset-token-xyz",
|
||||||
|
user: { email: "[email protected]", name: "Sam" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = only(test.sent);
|
||||||
|
expect(message.to).toEqual(["[email protected]"]);
|
||||||
|
expect(message.subject).toBe("Reset your Ekklesia password");
|
||||||
|
const link = (message.text ?? "").split("\n").find((line) => line.startsWith("https://")) ?? "";
|
||||||
|
const url = new URL(link);
|
||||||
|
expect(url.origin).toBe(authBaseUrl);
|
||||||
|
expect(url.pathname).toBe("/api/auth/reset-password/reset-token-xyz");
|
||||||
|
const callback = new URL(url.searchParams.get("callbackURL") ?? "");
|
||||||
|
expect(callback.origin).toBe(dashboardOrigin);
|
||||||
|
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no password, hash, or session material", async () => {
|
||||||
|
const test = harness();
|
||||||
|
|
||||||
|
await test.notifications.sendResetPassword({
|
||||||
|
token: "reset-token-xyz",
|
||||||
|
user: { email: "[email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialized = bodies(only(test.sent)).toLowerCase();
|
||||||
|
expect(serialized).not.toContain("hash");
|
||||||
|
expect(serialized).not.toContain("session");
|
||||||
|
// It does say the current password still works, which is the reassurance the flow needs.
|
||||||
|
expect(serialized).toContain("current password stays in place");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("identity notification failures", () => {
|
||||||
|
it("resolves rather than throwing when the relay fails, and reports a sanitized code", async () => {
|
||||||
|
const test = harness({
|
||||||
|
mail: {
|
||||||
|
close: async () => undefined,
|
||||||
|
send: async () => {
|
||||||
|
throw new MailError("MAIL_PROVIDER_UNAVAILABLE");
|
||||||
|
},
|
||||||
|
verify: async () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolving is the security property: Better Auth's own `/send-verification-email` rethrows
|
||||||
|
// whatever this throws, which would make "account exists" observable the moment SMTP broke.
|
||||||
|
await expect(
|
||||||
|
test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(test.failures).toEqual([
|
||||||
|
{ kind: "email-verification", reason: "MAIL_PROVIDER_UNAVAILABLE" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses an unrecognized thrown value rather than forwarding it", async () => {
|
||||||
|
const test = harness({
|
||||||
|
mail: {
|
||||||
|
close: async () => undefined,
|
||||||
|
send: async () => {
|
||||||
|
throw new Error("connect ECONNREFUSED 10.40.0.2:587 [email protected] pass=hunter2");
|
||||||
|
},
|
||||||
|
verify: async () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
|
||||||
|
|
||||||
|
expect(test.failures).toEqual([{ kind: "password-reset", reason: "MAIL_SEND_FAILED" }]);
|
||||||
|
const serialized = JSON.stringify(test.failures);
|
||||||
|
expect(serialized).not.toContain("hunter2");
|
||||||
|
expect(serialized).not.toContain("10.40.0.2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports MAIL_NOT_CONFIGURED, and still resolves, when the installation has no mail", async () => {
|
||||||
|
const test = harness({ configured: false });
|
||||||
|
|
||||||
|
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
|
||||||
|
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
|
||||||
|
|
||||||
|
expect(test.failures).toEqual([
|
||||||
|
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
]);
|
||||||
|
expect(test.sent).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes one fixed line by default, carrying neither the address nor the token", async () => {
|
||||||
|
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
try {
|
||||||
|
const notifications = createIdentityNotifications({
|
||||||
|
authBaseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
mail: undefined,
|
||||||
|
});
|
||||||
|
await notifications.sendResetPassword({
|
||||||
|
token: "reset-token-xyz",
|
||||||
|
user: { email: "[email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errors).toHaveBeenCalledTimes(1);
|
||||||
|
const line = String(errors.mock.calls[0]?.[0]);
|
||||||
|
expect(line).toBe("[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED");
|
||||||
|
expect(line).not.toContain("secret-person");
|
||||||
|
expect(line).not.toContain("reset-token-xyz");
|
||||||
|
} finally {
|
||||||
|
errors.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("identity notification message safety", () => {
|
||||||
|
it("escapes a display name rather than letting it close an element", async () => {
|
||||||
|
const test = harness();
|
||||||
|
|
||||||
|
await test.notifications.sendVerificationEmail({
|
||||||
|
token,
|
||||||
|
user: { email: "[email protected]", name: 'Sam"><script>alert(1)</script>' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = only(test.sent);
|
||||||
|
expect(message.html).not.toContain("<script>");
|
||||||
|
expect(message.html).toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an unusable display name instead of failing the whole message", async () => {
|
||||||
|
const test = harness();
|
||||||
|
|
||||||
|
// A stored name carrying a control character would be refused by the message validator, which
|
||||||
|
// would turn a cosmetic problem into an undeliverable password reset.
|
||||||
|
await test.notifications.sendResetPassword({
|
||||||
|
token: "t",
|
||||||
|
user: { email: "[email protected]", name: "Sam\r\nBcc: [email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = only(test.sent);
|
||||||
|
expect(message.text).toContain("Hello,");
|
||||||
|
expect(message.text).not.toContain("[email protected]");
|
||||||
|
expect(test.failures).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to send to an address the mail contract rejects, without throwing", async () => {
|
||||||
|
const test = harness();
|
||||||
|
|
||||||
|
await test.notifications.sendVerificationEmail({
|
||||||
|
token,
|
||||||
|
user: { email: "[email protected], [email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(test.sent).toHaveLength(0);
|
||||||
|
expect(test.failures).toEqual([
|
||||||
|
{ kind: "email-verification", reason: "MAIL_RECIPIENT_INVALID" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import { identityAdapterSchema } from "../src/identity/schema.js";
|
|||||||
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
|
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
baseUrl: "https://identity.example.test",
|
baseUrl: "https://identity.example.test",
|
||||||
|
dashboardOrigin: "https://identity.example.test",
|
||||||
publicSignupEnabled: false,
|
publicSignupEnabled: false,
|
||||||
secret: "correct-horse-battery-staple-0123456789",
|
secret: "correct-horse-battery-staple-0123456789",
|
||||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||||
@@ -109,18 +110,21 @@ describe("Core identity runtime construction boundary", () => {
|
|||||||
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
|
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables exactly email/password (autoSignIn disabled) and rate limiting, nothing else", () => {
|
it("enables exactly email/password, verification, and rate limiting, nothing else", () => {
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
|
||||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
|
||||||
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
||||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||||
|
// Exhaustive on purpose: a new top-level Better Auth feature has to be added here deliberately,
|
||||||
|
// never by inheriting a default.
|
||||||
expect(Object.keys(options).sort()).toEqual([
|
expect(Object.keys(options).sort()).toEqual([
|
||||||
"advanced",
|
"advanced",
|
||||||
"baseURL",
|
"baseURL",
|
||||||
"database",
|
"database",
|
||||||
"emailAndPassword",
|
"emailAndPassword",
|
||||||
|
"emailVerification",
|
||||||
"logger",
|
"logger",
|
||||||
"onAPIError",
|
"onAPIError",
|
||||||
"rateLimit",
|
"rateLimit",
|
||||||
@@ -133,13 +137,60 @@ describe("Core identity runtime construction boundary", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
maxPasswordLength: 128,
|
maxPasswordLength: 128,
|
||||||
minPasswordLength: 8,
|
minPasswordLength: 8,
|
||||||
|
// A verified address is a precondition for holding a session (ADR 0038).
|
||||||
|
requireEmailVerification: true,
|
||||||
|
resetPasswordTokenExpiresIn: 3600,
|
||||||
|
// Password reset revokes that human's other sessions, so recovering from a compromise is not
|
||||||
|
// cosmetic.
|
||||||
|
revokeSessionsOnPasswordReset: true,
|
||||||
});
|
});
|
||||||
|
expect(options.emailVerification).toEqual({
|
||||||
|
// Following an emailed link never mints a session: mail infrastructure dereferences links.
|
||||||
|
autoSignInAfterVerification: false,
|
||||||
|
expiresIn: 3600,
|
||||||
|
sendOnSignIn: true,
|
||||||
|
sendOnSignUp: true,
|
||||||
|
});
|
||||||
|
// No `customRules`: production inherits Better Auth's own stricter per-path defaults.
|
||||||
expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
|
expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
|
||||||
expect(options.baseURL).toBe(validConfig().baseUrl);
|
expect(options.baseURL).toBe(validConfig().baseUrl);
|
||||||
expect(options.secret).toBe(validConfig().secret);
|
expect(options.secret).toBe(validConfig().secret);
|
||||||
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wires the injected notifier into both delivery callbacks, and neither without it", () => {
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
const sendResetPassword = vi.fn(async () => undefined);
|
||||||
|
const sendVerificationEmail = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
createIdentityRuntime({
|
||||||
|
config: validConfig(),
|
||||||
|
db: fakeDb(),
|
||||||
|
notifications: { sendResetPassword, sendVerificationEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||||
|
expect((options.emailAndPassword as Record<string, unknown>).sendResetPassword).toBe(
|
||||||
|
sendResetPassword,
|
||||||
|
);
|
||||||
|
expect((options.emailVerification as Record<string, unknown>).sendVerificationEmail).toBe(
|
||||||
|
sendVerificationEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
betterAuthMock.mockClear();
|
||||||
|
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
const [withoutMail] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||||
|
// An installation with no mail configures the identical policy and simply cannot deliver.
|
||||||
|
// Crucially it does **not** relax `requireEmailVerification`.
|
||||||
|
expect(withoutMail.emailAndPassword).toMatchObject({ requireEmailVerification: true });
|
||||||
|
expect(
|
||||||
|
(withoutMail.emailAndPassword as Record<string, unknown>).sendResetPassword,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
(withoutMail.emailVerification as Record<string, unknown>).sendVerificationEmail,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
|
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
|
||||||
@@ -254,7 +305,7 @@ describe("Core identity runtime construction boundary", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only widens the sign-in/sign-up rate-limit window when a narrow override is explicitly injected", () => {
|
it("only widens the four strict rate-limit windows when a narrow override is explicitly injected", () => {
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
|
||||||
createIdentityRuntime({
|
createIdentityRuntime({
|
||||||
@@ -266,6 +317,10 @@ describe("Core identity runtime construction boundary", () => {
|
|||||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||||
expect(options.rateLimit).toEqual({
|
expect(options.rateLimit).toEqual({
|
||||||
customRules: {
|
customRules: {
|
||||||
|
// The two paths that trigger an email are included, so a test suite driving many recovery
|
||||||
|
// requests is not throttled by Better Auth's 3-per-60-seconds default for them.
|
||||||
|
"/request-password-reset": { max: 1000, window: 1 },
|
||||||
|
"/send-verification-email": { max: 1000, window: 1 },
|
||||||
"/sign-in/email": { max: 1000, window: 1 },
|
"/sign-in/email": { max: 1000, window: 1 },
|
||||||
"/sign-up/email": { max: 1000, window: 1 },
|
"/sign-up/email": { max: 1000, window: 1 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural guarantees about email verification and password recovery that no runtime assertion can
|
||||||
|
* make (ADR 0038).
|
||||||
|
*
|
||||||
|
* These read the repository itself, because what matters here is what code *exists* and what depends
|
||||||
|
* on what: that identity never learns an SMTP detail, that no token store was reimplemented
|
||||||
|
* alongside Better Auth's, that a device identity has no route into a human email flow, and that
|
||||||
|
* nothing in a browser bundle can reach any of it.
|
||||||
|
*/
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
|
async function readTypeScriptFiles(
|
||||||
|
directory: string,
|
||||||
|
): Promise<ReadonlyArray<{ readonly path: string; readonly text: string }>> {
|
||||||
|
const results: Array<{ path: string; text: string }> = [];
|
||||||
|
const walk = async (current: string): Promise<void> => {
|
||||||
|
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||||
|
const full = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "node_modules" || entry.name === "dist") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await walk(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||||
|
results.push({ path: full, text: await readFile(full, "utf8") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await walk(path.join(repositoryRoot, directory));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips block comments and whole-line `//` comments: these assertions are about code, and prose
|
||||||
|
* explaining a rule inevitably quotes the token the rule forbids. */
|
||||||
|
function withoutComments(text: string): string {
|
||||||
|
return text
|
||||||
|
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.trimStart().startsWith("//"))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("identity email flows stay above the mail transport", () => {
|
||||||
|
it("names no SMTP or provider detail anywhere in the identity feature", async () => {
|
||||||
|
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
|
||||||
|
const code = withoutComments(file.text).toLowerCase();
|
||||||
|
for (const forbidden of [
|
||||||
|
"nodemailer",
|
||||||
|
"smtpserver",
|
||||||
|
"starttls",
|
||||||
|
"createtransport",
|
||||||
|
"587",
|
||||||
|
"465",
|
||||||
|
]) {
|
||||||
|
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches the transport only through the provider seam", async () => {
|
||||||
|
const notifications = await readFile(
|
||||||
|
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The whole mail surface identity may use: a validated message and a `send`. Not a config, not
|
||||||
|
// a transport, not a credential.
|
||||||
|
expect(notifications).toContain('from "../mail/index.js"');
|
||||||
|
expect(withoutComments(notifications)).not.toContain("MailProviderConfig");
|
||||||
|
expect(withoutComments(notifications)).not.toContain("createSmtpMailProvider");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Better Auth owns every token", () => {
|
||||||
|
it("adds no Ekklesia table, column, or store for a verification or reset token", async () => {
|
||||||
|
const schemas = [
|
||||||
|
path.join("core", "src", "identity", "schema.ts"),
|
||||||
|
path.join("core", "src", "authorization", "schema.ts"),
|
||||||
|
path.join("core", "src", "devices", "schema.ts"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const relative of schemas) {
|
||||||
|
const code = withoutComments(
|
||||||
|
await readFile(path.join(repositoryRoot, relative), "utf8"),
|
||||||
|
).toLowerCase();
|
||||||
|
// A parallel token table would be a second expiry clock, a second consumption rule, and a
|
||||||
|
// second thing to get wrong. `ekklesia_core_verification` is Better Auth's own table.
|
||||||
|
expect(code, relative).not.toContain("reset_token");
|
||||||
|
expect(code, relative).not.toContain("verification_token");
|
||||||
|
expect(code, relative).not.toContain("password_reset");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never hashes, stores, or compares a token itself", async () => {
|
||||||
|
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
|
||||||
|
const code = withoutComments(file.text);
|
||||||
|
expect(code, file.path).not.toContain("createHash");
|
||||||
|
expect(code, file.path).not.toContain("timingSafeEqual");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates no token, link, address, or message body into anything it logs", async () => {
|
||||||
|
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
|
||||||
|
const code = withoutComments(file.text);
|
||||||
|
for (const call of code.matchAll(/console\.\w+\(([^\n]*)/gu)) {
|
||||||
|
// Fixed prose in a log line is fine; a **substituted value** is what could carry a secret.
|
||||||
|
// Only the interpolations are examined, so "email was not delivered" passes while
|
||||||
|
// `${user.email}` could not.
|
||||||
|
for (const substitution of (call[1] ?? "").matchAll(/\$\{([^}]*)\}/gu)) {
|
||||||
|
const expression = (substitution[1] ?? "").toLowerCase();
|
||||||
|
for (const forbidden of ["token", "url", "html", "text", "email", "recipient", "body"]) {
|
||||||
|
expect(expression, `${file.path} logs ${forbidden}`).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("managed devices have no part in human email identity", () => {
|
||||||
|
it("gives a device no address, password, or verification state", async () => {
|
||||||
|
for (const file of await readTypeScriptFiles(path.join("core", "src", "devices"))) {
|
||||||
|
const code = withoutComments(file.text).toLowerCase();
|
||||||
|
for (const forbidden of ["email", "password", "emailverified", "sendresetpassword"]) {
|
||||||
|
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the device store out of the identity notification path entirely", async () => {
|
||||||
|
const notifications = await readFile(
|
||||||
|
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifications).not.toContain("devices");
|
||||||
|
expect(notifications).not.toContain("Device");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the browser shell reaches none of it", () => {
|
||||||
|
it("imports neither Better Auth nor Core's mail from any browser source", async () => {
|
||||||
|
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||||
|
expect(file.text, file.path).not.toContain("better-auth");
|
||||||
|
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
|
||||||
|
expect(file.text, file.path).not.toContain("nodemailer");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares no server identity or mail dependency in the shell package", async () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(path.join(repositoryRoot, "surfaces", "shell", "package.json"), "utf8"),
|
||||||
|
) as Record<string, Record<string, string> | undefined>;
|
||||||
|
const declared = Object.keys({
|
||||||
|
...(manifest.dependencies ?? {}),
|
||||||
|
...(manifest.devDependencies ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(declared).not.toContain("better-auth");
|
||||||
|
expect(declared).not.toContain("nodemailer");
|
||||||
|
expect(declared).not.toContain("@ekklesia/core");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never puts a reset token in browser storage", async () => {
|
||||||
|
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||||
|
const code = withoutComments(file.text);
|
||||||
|
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
||||||
|
// the life of one form and nowhere else.
|
||||||
|
expect(code, file.path).not.toContain("localStorage");
|
||||||
|
expect(code, file.path).not.toContain("sessionStorage");
|
||||||
|
expect(code, file.path).not.toContain("document.cookie");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("public signup stays gone", () => {
|
||||||
|
it("offers no registration path from any browser source", async () => {
|
||||||
|
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||||
|
const code = withoutComments(file.text);
|
||||||
|
// Email verification is not a licence to self-register (ADR 0038).
|
||||||
|
expect(code, file.path).not.toContain("/sign-up");
|
||||||
|
expect(code, file.path).not.toContain("signUp");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,30 +42,42 @@ async function readTextFiles(directory: string): Promise<Array<{ path: string; t
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("outbound email stays server-only", () => {
|
describe("outbound email stays server-only", () => {
|
||||||
it("declares the SMTP dependency in Core alone", async () => {
|
it("declares the SMTP transport in Core alone", async () => {
|
||||||
const core = await readJson("core/package.json");
|
const core = await readJson("core/package.json");
|
||||||
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
|
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
|
||||||
|
|
||||||
|
const mailPackages = (names: readonly string[]): readonly string[] =>
|
||||||
|
names.filter((name) => /nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name));
|
||||||
|
|
||||||
// Every package a browser can end up executing, plus the contracts barrel a surface must not
|
// Every package a browser can end up executing, plus the contracts barrel a surface must not
|
||||||
// reach. None may carry a mail transport.
|
// reach. None may carry anything mail-related at all, in either dependency list.
|
||||||
for (const manifest of [
|
for (const manifest of [
|
||||||
"packages/contracts/package.json",
|
"packages/contracts/package.json",
|
||||||
"packages/module-sdk/package.json",
|
"packages/module-sdk/package.json",
|
||||||
"packages/module-ui-sdk/package.json",
|
"packages/module-ui-sdk/package.json",
|
||||||
"packages/module-ui-host/package.json",
|
"packages/module-ui-host/package.json",
|
||||||
"surfaces/shell/package.json",
|
"surfaces/shell/package.json",
|
||||||
"apps/api/package.json",
|
|
||||||
]) {
|
]) {
|
||||||
const parsed = await readJson(manifest);
|
const parsed = await readJson(manifest);
|
||||||
const dependencies = {
|
expect(
|
||||||
...((parsed.dependencies ?? {}) as Record<string, string>),
|
mailPackages([
|
||||||
...((parsed.devDependencies ?? {}) as Record<string, string>),
|
...Object.keys((parsed.dependencies ?? {}) as object),
|
||||||
};
|
...Object.keys((parsed.devDependencies ?? {}) as object),
|
||||||
const mailDependencies = Object.keys(dependencies).filter((name) =>
|
]),
|
||||||
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name),
|
manifest,
|
||||||
);
|
).toEqual([]);
|
||||||
expect(mailDependencies, manifest).toEqual([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The API executable ships no mail package of any kind: it reaches SMTP only through Core's
|
||||||
|
// provider. Its *test* dependencies may include `smtp-server`, which is a receiving server and
|
||||||
|
// has no capability to send anything — that is what lets the verification and recovery
|
||||||
|
// integration suites capture a real message instead of asserting against a fake (ADR 0038).
|
||||||
|
const api = await readJson("apps/api/package.json");
|
||||||
|
expect(mailPackages(Object.keys((api.dependencies ?? {}) as object))).toEqual([]);
|
||||||
|
expect(mailPackages(Object.keys((api.devDependencies ?? {}) as object))).toEqual([
|
||||||
|
"@types/smtp-server",
|
||||||
|
"smtp-server",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never imports the transport outside Core's own mail module", async () => {
|
it("never imports the transport outside Core's own mail module", async () => {
|
||||||
@@ -149,13 +161,42 @@ describe("outbound email stays server-only", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hardcodes no provider hostname in Core", async () => {
|
it("hardcodes no provider hostname in Core", async () => {
|
||||||
// Google Workspace is a deployment target, not the architecture.
|
// Google Workspace is a deployment target, not the architecture. It is the deployment that
|
||||||
|
// exposed the generic `EHLO` defect the HELO-name setting fixes, and it is named nowhere the
|
||||||
|
// code could come to depend on it.
|
||||||
for (const file of await readTextFiles("core/src/mail")) {
|
for (const file of await readTextFiles("core/src/mail")) {
|
||||||
expect(file.text.toLowerCase(), file.path).not.toContain("gmail");
|
const text = file.text.toLowerCase();
|
||||||
expect(file.text.toLowerCase(), file.path).not.toContain("smtp-relay");
|
for (const forbidden of [
|
||||||
expect(file.text.toLowerCase(), file.path).not.toContain("resend");
|
"gmail",
|
||||||
expect(file.text.toLowerCase(), file.path).not.toContain("brevo");
|
"smtp-relay",
|
||||||
|
"googlemail",
|
||||||
|
"google.com",
|
||||||
|
"outlook",
|
||||||
|
"office365",
|
||||||
|
"sendgrid",
|
||||||
|
"mailgun",
|
||||||
|
"postmark",
|
||||||
|
"resend",
|
||||||
|
"brevo",
|
||||||
|
]) {
|
||||||
|
expect(text, `${file.path} names ${forbidden}`).not.toContain(forbidden);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the SMTP client identity to nothing, so no deployment's name is baked in", async () => {
|
||||||
|
const config = await readFile(path.join(repositoryRoot, "core/src/mail/config.ts"), "utf8");
|
||||||
|
const provider = await readFile(
|
||||||
|
path.join(repositoryRoot, "core/src/mail/smtp-provider.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The value that fixed a real deployment is configuration, never a constant. A fallback literal
|
||||||
|
// here would be one church's domain compiled into every installation.
|
||||||
|
expect(config).not.toContain("riverofglorymn");
|
||||||
|
expect(provider).not.toContain("riverofglorymn");
|
||||||
|
// The provider passes it through and supplies nothing of its own when it is absent.
|
||||||
|
expect(provider).toContain("config.heloName === undefined ? {} : { name: config.heloName }");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never disables TLS certificate verification", async () => {
|
it("never disables TLS certificate verification", async () => {
|
||||||
|
|||||||
@@ -193,6 +193,116 @@ describe("host mail configuration", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SMTP client identity (HELO name)", () => {
|
||||||
|
/**
|
||||||
|
* A real deployment defect, and a generic one.
|
||||||
|
*
|
||||||
|
* An SMTP client must greet the server with its own identity. Nodemailer derives that from the
|
||||||
|
* machine hostname and falls back to the address literal `[127.0.0.1]` when the hostname is not
|
||||||
|
* fully qualified — the normal state of a container. Some relays close the connection on that
|
||||||
|
* greeting with a `421` before any message is offered, which reaches a caller as an
|
||||||
|
* indistinguishable `MAIL_PROVIDER_UNAVAILABLE`. The deployment can now say what to greet as.
|
||||||
|
*/
|
||||||
|
it("omits the setting entirely when the deployment configures none", () => {
|
||||||
|
const config = createMailProviderConfig(validConfig);
|
||||||
|
|
||||||
|
// Absent means Nodemailer's own derivation, unchanged. The key is not present at all rather
|
||||||
|
// than present-and-undefined, so nothing downstream can mistake it for a configured empty name.
|
||||||
|
expect(config).not.toHaveProperty("heloName");
|
||||||
|
expect(createMailProviderConfig({ ...validConfig, heloName: "" })).not.toHaveProperty(
|
||||||
|
"heloName",
|
||||||
|
);
|
||||||
|
expect(createMailProviderConfig({ ...validConfig, heloName: undefined })).not.toHaveProperty(
|
||||||
|
"heloName",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a two-label domain", "example.org"],
|
||||||
|
["a subdomain", "mail.example.org"],
|
||||||
|
["a deep subdomain", "smtp.relay.mail.example.org"],
|
||||||
|
["hyphens inside labels", "out-bound.example-church.org"],
|
||||||
|
["digits", "mx1.example2.org"],
|
||||||
|
["mixed case", "Mail.Example.ORG"],
|
||||||
|
])("accepts %s", (_label, heloName) => {
|
||||||
|
expect(createMailProviderConfig({ ...validConfig, heloName })).toMatchObject({ heloName });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a scheme", "smtp://example.org"],
|
||||||
|
["a port", "example.org:587"],
|
||||||
|
["a path", "example.org/relay"],
|
||||||
|
["a space", "example org"],
|
||||||
|
["a leading space", " example.org"],
|
||||||
|
["a trailing space", "example.org "],
|
||||||
|
["a carriage return", "example.org\r"],
|
||||||
|
["a line feed", "example.org\nEHLO evil.example.net"],
|
||||||
|
["a tab", "example.org\t"],
|
||||||
|
["a comma", "example.org,evil.example.net"],
|
||||||
|
["an unqualified single label", "localhost"],
|
||||||
|
["a machine name with no domain", "ekklesia-host"],
|
||||||
|
["a bracketed address literal", "[127.0.0.1]"],
|
||||||
|
["a bare IPv4 address", "192.0.2.1"],
|
||||||
|
["a trailing dot", "example.org."],
|
||||||
|
["a leading dot", ".example.org"],
|
||||||
|
["consecutive dots", "example..org"],
|
||||||
|
["a leading hyphen in a label", "-example.org"],
|
||||||
|
["a trailing hyphen in a label", "example-.org"],
|
||||||
|
["an email address", "[email protected]"],
|
||||||
|
["prose", "our church mail server"],
|
||||||
|
["nothing but dots", "..."],
|
||||||
|
["an over-long name", `${"a".repeat(250)}.example.org`],
|
||||||
|
])("refuses %s", (_label, heloName) => {
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, heloName })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_HELO_NAME_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unqualified name, because that is the input the fallback already rejects", () => {
|
||||||
|
// Configuring a single label would reproduce the very failure this setting exists to fix:
|
||||||
|
// Nodemailer discards a non-FQDN hostname and substitutes the address literal.
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, heloName: "ekklesia" })).toThrow(
|
||||||
|
MailConfigError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never carries the offending value in the error", () => {
|
||||||
|
try {
|
||||||
|
createMailProviderConfig({ ...validConfig, heloName: "smtp://secret-internal-name:2525" });
|
||||||
|
throw new Error("expected a refusal");
|
||||||
|
} catch (error) {
|
||||||
|
const serialized = `${(error as Error).message} ${(error as Error).stack ?? ""}`;
|
||||||
|
expect(serialized).not.toContain("secret-internal-name");
|
||||||
|
expect(serialized).not.toContain("2525");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is described to an operator, because it is infrastructure rather than a credential", () => {
|
||||||
|
const described = describeMailProvider(
|
||||||
|
createMailProviderConfig({
|
||||||
|
...validConfig,
|
||||||
|
heloName: "mail.example.org",
|
||||||
|
password,
|
||||||
|
username: "[email protected]",
|
||||||
|
}) as Parameters<typeof describeMailProvider>[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// It is announced in the clear on every connection, so printing it leaks nothing — and it is
|
||||||
|
// exactly what an operator needs when a relay has refused the greeting.
|
||||||
|
expect(described).toContain("HELO mail.example.org");
|
||||||
|
expect(described).not.toContain(password);
|
||||||
|
expect(described).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about a HELO name when none is configured", () => {
|
||||||
|
const described = describeMailProvider(
|
||||||
|
createMailProviderConfig(validConfig) as Parameters<typeof describeMailProvider>[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(described).not.toContain("HELO");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("transactional message validation", () => {
|
describe("transactional message validation", () => {
|
||||||
const message = {
|
const message = {
|
||||||
subject: "Verify your email address",
|
subject: "Verify your email address",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo, Socket } from "node:net";
|
||||||
import { createServer, type Server } from "node:net";
|
import { createServer, type Server } from "node:net";
|
||||||
import { SMTPServer } from "smtp-server";
|
import { SMTPServer } from "smtp-server";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
@@ -25,6 +25,8 @@ interface CapturedMessage {
|
|||||||
readonly from: string;
|
readonly from: string;
|
||||||
readonly to: readonly string[];
|
readonly to: readonly string[];
|
||||||
readonly data: string;
|
readonly data: string;
|
||||||
|
/** The argument this client actually sent in `EHLO`, lowercased by the server. */
|
||||||
|
readonly greetedAs: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Harness {
|
interface Harness {
|
||||||
@@ -52,6 +54,14 @@ interface HarnessOptions {
|
|||||||
readonly rejectRecipients?: readonly string[];
|
readonly rejectRecipients?: readonly string[];
|
||||||
/** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */
|
/** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */
|
||||||
readonly offerStartTls?: boolean;
|
readonly offerStartTls?: boolean;
|
||||||
|
/**
|
||||||
|
* Refuses any client whose `EHLO` argument is not exactly this name, as a relay that requires a
|
||||||
|
* fully qualified client identity does.
|
||||||
|
*
|
||||||
|
* The refusal is a `421`, the same class of response the real relay returned: a temporary failure
|
||||||
|
* that closes the connection before a message is offered.
|
||||||
|
*/
|
||||||
|
readonly requireGreeting?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
||||||
@@ -81,11 +91,26 @@ async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
captured.push({
|
captured.push({
|
||||||
data,
|
data,
|
||||||
from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address,
|
from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address,
|
||||||
|
greetedAs: session.hostNameAppearsAs,
|
||||||
to: session.envelope.rcptTo.map((entry) => entry.address),
|
to: session.envelope.rcptTo.map((entry) => entry.address),
|
||||||
});
|
});
|
||||||
callback();
|
callback();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onMailFrom(_address, session, callback) {
|
||||||
|
if (
|
||||||
|
options.requireGreeting !== undefined &&
|
||||||
|
session.hostNameAppearsAs !== options.requireGreeting.toLowerCase()
|
||||||
|
) {
|
||||||
|
const error = new Error("Try again later, closing connection.") as Error & {
|
||||||
|
responseCode?: number;
|
||||||
|
};
|
||||||
|
error.responseCode = 421;
|
||||||
|
callback(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
onRcptTo(address, _session, callback) {
|
onRcptTo(address, _session, callback) {
|
||||||
if (options.rejectRecipients?.includes(address.address)) {
|
if (options.rejectRecipients?.includes(address.address)) {
|
||||||
// 550 is a permanent refusal of this mailbox, which is what a real relay returns.
|
// 550 is a permanent refusal of this mailbox, which is what a real relay returns.
|
||||||
@@ -107,6 +132,64 @@ async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
return { captured, close, port: address.port };
|
return { captured, close, port: address.port };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A relay that refuses the greeting itself, byte for byte as the real one did.
|
||||||
|
*
|
||||||
|
* `smtp-server` exposes no `EHLO` hook — its earliest usable one is `MAIL FROM`, which
|
||||||
|
* `transporter.verify()` never reaches — so modelling this failure at the point it actually happens
|
||||||
|
* needs a socket that speaks the handful of SMTP verbs involved. That matters: the whole reason the
|
||||||
|
* defect was hard to diagnose is that it aborts the session *before* a message is offered, which is
|
||||||
|
* also why Doctor's send-nothing probe can see it.
|
||||||
|
*/
|
||||||
|
async function startGreetingStrictRelay(expected: string): Promise<Harness> {
|
||||||
|
const sockets = new Set<Socket>();
|
||||||
|
const server = createServer((socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on("close", () => sockets.delete(socket));
|
||||||
|
socket.on("error", () => undefined);
|
||||||
|
socket.write("220 relay.invalid ESMTP\r\n");
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk: Buffer) => {
|
||||||
|
buffer += chunk.toString("utf8");
|
||||||
|
let index = buffer.indexOf("\r\n");
|
||||||
|
while (index !== -1) {
|
||||||
|
const line = buffer.slice(0, index);
|
||||||
|
buffer = buffer.slice(index + 2);
|
||||||
|
const [verb = "", argument = ""] = line.split(" ");
|
||||||
|
if (verb.toUpperCase() === "EHLO" || verb.toUpperCase() === "HELO") {
|
||||||
|
if (argument.toLowerCase() !== expected.toLowerCase()) {
|
||||||
|
// The exact shape the deployment saw: a 421 naming no reason, then the door shut.
|
||||||
|
socket.write("421-4.7.0 Try again later, closing connection. (EHLO)\r\n");
|
||||||
|
socket.write("421 4.7.0 Goodbye\r\n");
|
||||||
|
socket.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.write("250-relay.invalid\r\n250 OK\r\n");
|
||||||
|
} else if (verb.toUpperCase() === "QUIT") {
|
||||||
|
socket.write("221 Bye\r\n");
|
||||||
|
socket.end();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
socket.write("250 OK\r\n");
|
||||||
|
}
|
||||||
|
index = buffer.indexOf("\r\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
const close = async (): Promise<void> => {
|
||||||
|
// `close()` waits for open sockets, and a refused client may still be holding one.
|
||||||
|
for (const socket of sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
};
|
||||||
|
openHarnesses.push(close);
|
||||||
|
return { captured: [], close, port: address.port };
|
||||||
|
}
|
||||||
|
|
||||||
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
|
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
|
||||||
const built = createMailProviderConfig(
|
const built = createMailProviderConfig(
|
||||||
{
|
{
|
||||||
@@ -355,3 +438,94 @@ describe.sequential("live SMTP transport", () => {
|
|||||||
expect(harness.captured).toHaveLength(1);
|
expect(harness.captured).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SMTP client identity on the wire", () => {
|
||||||
|
/**
|
||||||
|
* The regression guard for a real production defect.
|
||||||
|
*
|
||||||
|
* A deployment relaying through an IP-authorized relay could not send at all: every attempt
|
||||||
|
* surfaced as `MAIL_PROVIDER_UNAVAILABLE`. The relay was healthy — `swaks` sent from the same
|
||||||
|
* machine — and the cause was the greeting. An SMTP client identifies itself in `EHLO`, and with
|
||||||
|
* nothing configured Nodemailer derives that from the machine hostname, substituting the address
|
||||||
|
* literal `[127.0.0.1]` whenever the hostname is not fully qualified. The relay closed the
|
||||||
|
* connection on it with a `421`.
|
||||||
|
*
|
||||||
|
* These tests are written against the wire rather than against the transport options, because the
|
||||||
|
* defect was invisible in the options: everything about the configuration looked correct.
|
||||||
|
*/
|
||||||
|
it("greets with the configured name, exactly as configured", async () => {
|
||||||
|
const harness = await startSmtpServer();
|
||||||
|
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||||
|
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never greets as the address literal when a name is configured", async () => {
|
||||||
|
const harness = await startSmtpServer();
|
||||||
|
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||||
|
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exact greeting the relay refused.
|
||||||
|
expect(harness.captured[0]?.greetedAs).not.toBe("[127.0.0.1]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers to a relay that demands a fully qualified greeting", async () => {
|
||||||
|
const harness = await startSmtpServer({ requireGreeting: "mail.example.org" });
|
||||||
|
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||||
|
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({ subject: "Accepted", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(harness.captured).toHaveLength(1);
|
||||||
|
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is refused by that same relay when no name is configured", async () => {
|
||||||
|
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||||
|
const mail = provider(config(harness.port));
|
||||||
|
|
||||||
|
// Deterministic regardless of what this machine is called: the server accepts exactly one name,
|
||||||
|
// and Nodemailer's derivation cannot produce `mail.example.org` on any host.
|
||||||
|
await expect(
|
||||||
|
mail.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" })),
|
||||||
|
).rejects.toMatchObject({ code: "MAIL_PROVIDER_UNAVAILABLE" });
|
||||||
|
expect(harness.captured).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the refused greeting into no error a caller can see", async () => {
|
||||||
|
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||||
|
const mail = provider(config(harness.port));
|
||||||
|
|
||||||
|
const failure = await mail
|
||||||
|
.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" }))
|
||||||
|
.catch((error: unknown) => error);
|
||||||
|
|
||||||
|
// A `421` is SMTP's "try later", so it stays `MAIL_PROVIDER_UNAVAILABLE` rather than becoming a
|
||||||
|
// permanent rejection — and the server's response text never reaches the caller.
|
||||||
|
const serialized = `${(failure as Error).message} ${(failure as Error).stack ?? ""}`;
|
||||||
|
expect(serialized).not.toContain("Try again later");
|
||||||
|
expect(serialized).not.toContain("closing connection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still verifies through the same greeting Doctor's probe will use", async () => {
|
||||||
|
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||||
|
const configured = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||||
|
const unconfigured = provider(config(harness.port));
|
||||||
|
|
||||||
|
// Doctor builds its provider from the identical validated configuration, so an operator whose
|
||||||
|
// relay refuses the greeting sees it in `pnpm doctor` rather than only when a reset email
|
||||||
|
// silently fails to arrive.
|
||||||
|
await expect(configured.verify()).resolves.toBeUndefined();
|
||||||
|
await expect(unconfigured.verify()).rejects.toMatchObject({
|
||||||
|
code: "MAIL_PROVIDER_UNAVAILABLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -27,6 +27,7 @@ function fakeModule(
|
|||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -193,7 +194,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function manifestWith(
|
|||||||
id = "announcements",
|
id = "announcements",
|
||||||
) {
|
) {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
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", () => {
|
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
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", () => {
|
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
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", () => {
|
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
|||||||
],
|
],
|
||||||
])("rejects %s", (_label, declaration) => {
|
])("rejects %s", (_label, declaration) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
|||||||
"valueOf",
|
"valueOf",
|
||||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
|||||||
"",
|
"",
|
||||||
])("rejects the malformed configuration key %s", (key) => {
|
])("rejects the malformed configuration key %s", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
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" }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -199,7 +200,7 @@ afterAll(async () => {
|
|||||||
* by eligibility resolution. */
|
* by eligibility resolution. */
|
||||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -208,6 +209,7 @@ function fakeModule(id: string, dependencies?: Record<string, unknown>): Discove
|
|||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -36,6 +36,7 @@ function fakeModule(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface PermissionInput {
|
|||||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function manifest(
|
|||||||
permissions: readonly Record<string, string>[] = [],
|
permissions: readonly Record<string, string>[] = [],
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -861,6 +861,7 @@ describe("module HTTP routes", () => {
|
|||||||
expect(runtime.routes).toHaveLength(3);
|
expect(runtime.routes).toHaveLength(3);
|
||||||
for (const route of runtime.routes) {
|
for (const route of runtime.routes) {
|
||||||
expect(Object.keys(route).sort()).toEqual([
|
expect(Object.keys(route).sort()).toEqual([
|
||||||
|
"bodyMode",
|
||||||
"handler",
|
"handler",
|
||||||
"maxBodyBytes",
|
"maxBodyBytes",
|
||||||
"method",
|
"method",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function manifestFor(
|
|||||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -29,6 +29,7 @@ function fakeModule(
|
|||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ interface ModuleFixture {
|
|||||||
readonly ui?: string;
|
readonly ui?: string;
|
||||||
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
||||||
readonly display?: string;
|
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> {
|
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,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
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 mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
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
|
/** 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());
|
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,87 @@
|
|||||||
|
# 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} {
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 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} {
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,467 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
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;
|
||||||
|
- 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.1 \
|
||||||
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
|
--tag ekklesia:0.1.1 \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
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.**
|
||||||
|
|
||||||
|
| 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,84 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Exactly one trusted peer: this proxy. Set here from the same variable that fixes the proxy's
|
||||||
|
# address, so the two cannot drift apart. These override any value in ekklesia.env.
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user