Compare commits
79
Commits
fcc531a39c
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc504eee0a | ||
|
|
5abc8be167 | ||
|
|
12dca3e8a4 | ||
|
|
0ec1755279 | ||
|
|
874deabea3 | ||
|
|
62951975da | ||
|
|
703d92436b | ||
|
|
43911a088d | ||
|
|
3a2f5d68f6 | ||
|
|
3827841205 | ||
|
|
2152eb036a | ||
|
|
e1ac1da416 | ||
|
|
dcbd7613ee | ||
|
|
dbb4648a35 | ||
|
|
f1d6501239 | ||
|
|
54ac6ab83f | ||
|
|
fa493365bd | ||
|
|
f50e8f4201 | ||
|
|
ff8a3e8097 | ||
|
|
b2efb4b029 | ||
|
|
f63f80f2f0 | ||
|
|
cbf5962d04 | ||
|
|
38cf52757d | ||
|
|
5162b4cc77 | ||
|
|
8ea4d4d2e4 | ||
|
|
55accd0e65 | ||
|
|
a56a853319 | ||
|
|
b642d8fe30 | ||
|
|
278e66049f | ||
|
|
c737ebb472 | ||
|
|
7cbe4e1b2c | ||
|
|
481e80118b | ||
|
|
167b4c4f14 | ||
|
|
b593abe7a5 | ||
|
|
e12ef10a65 | ||
|
|
4468335e8f | ||
|
|
756e9b821c | ||
|
|
ef1383d080 | ||
|
|
fc422b6d8d | ||
|
|
d5d4928b9f | ||
|
|
a12cbe9600 | ||
|
|
220b7b152a | ||
|
|
aaec9789c4 | ||
|
|
d593c10f1d | ||
|
|
06e55cebd5 | ||
|
|
f3cd5a4152 | ||
|
|
3a26ef49ad | ||
|
|
3ca6b2cb32 | ||
|
|
75324af045 | ||
|
|
f445a83ea5 | ||
|
|
a130eb78ea | ||
|
|
8127cc87b4 | ||
|
|
2969ed4992 | ||
|
|
14ac46bd3d | ||
|
|
bb7a9eb351 | ||
|
|
20d5128bad | ||
|
|
ca3f37d5f9 | ||
|
|
e36485bf99 | ||
|
|
c444ff570b | ||
|
|
07835b7b56 | ||
|
|
e03d38fcab | ||
|
|
b14613decd | ||
|
|
3563549e86 | ||
|
|
450d65116f | ||
|
|
ea28f8aa2e | ||
|
|
3581ad7c8e | ||
|
|
2b4f0afb72 | ||
|
|
a835215eb8 | ||
|
|
fa3021e49b | ||
|
|
ba32499456 | ||
|
|
80189c8ae2 | ||
|
|
3ce4fe03fe | ||
|
|
c8fa13dd6a | ||
|
|
c131543abf | ||
|
|
a8c7c50b4d | ||
|
|
91aa9c2930 | ||
|
|
2e0fe059c3 | ||
|
|
be5ed7853d | ||
|
|
fc6c8045c4 |
@@ -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
|
||||
@@ -1,4 +1,8 @@
|
||||
# Local development only. Replace every credential outside a developer workstation.
|
||||
#
|
||||
# `pnpm doctor` loads this file's uncommitted `.env` counterpart and evaluates exactly the host
|
||||
# infrastructure configuration `pnpm api:start` would use, so the readiness report reflects the real
|
||||
# deployment without the API process running.
|
||||
|
||||
# Required for a local workstation, and only ever correct there. Anything else — including an
|
||||
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
|
||||
@@ -44,3 +48,63 @@ S3_BUCKET_PREFIX=ekklesia
|
||||
# Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider —
|
||||
# physical buckets are derived from a module's manifest declaration, not configured here.
|
||||
S3_BUCKET=ekklesia-development
|
||||
|
||||
# Ekklesia's outbound email transport (ADR 0037). Absent EKKLESIA_SMTP_HOST means this installation
|
||||
# sends no email, which is a complete deployment: nothing in the platform sends any yet, and a
|
||||
# future feature that needs to is told so rather than crashing.
|
||||
#
|
||||
# These are host infrastructure. No module ever sees them, and no feature chooses its own sender:
|
||||
# the From address below is the only one this platform can send as.
|
||||
#
|
||||
# EKKLESIA_SMTP_HOST is a bare hostname — never a URL, a port, or a connection string.
|
||||
# EKKLESIA_SMTP_SECURITY is exactly one of:
|
||||
# implicit-tls TLS from the first byte, conventionally port 465
|
||||
# starttls plaintext connect then a *required* upgrade, conventionally 587 (the default)
|
||||
# plaintext no TLS at all; development only, and production refuses it outright
|
||||
# EKKLESIA_SMTP_PORT is optional and defaults to the conventional port for the mode.
|
||||
#
|
||||
# Credentials are optional **as a pair**, because a relay authorized by source IP has neither.
|
||||
# Setting one without the other is refused rather than silently connecting anonymously. Never commit
|
||||
# a real password: this file is in every clone.
|
||||
#
|
||||
# A Google Workspace SMTP Relay deployment typically looks like:
|
||||
# EKKLESIA_SMTP_HOST=<the relay host from Google Workspace Admin>
|
||||
# EKKLESIA_SMTP_PORT=587
|
||||
# EKKLESIA_SMTP_SECURITY=starttls
|
||||
# [email protected]
|
||||
# Whether it also needs a username and password depends on the relay's authorization setting in
|
||||
# Google Workspace Admin, which varies per deployment. See docs/development/email.md.
|
||||
# EKKLESIA_SMTP_HOST=
|
||||
# EKKLESIA_SMTP_PORT=587
|
||||
# EKKLESIA_SMTP_SECURITY=starttls
|
||||
# EKKLESIA_SMTP_USERNAME=
|
||||
# EKKLESIA_SMTP_PASSWORD=
|
||||
# [email protected]
|
||||
# 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.
|
||||
|
||||
+127
-12
@@ -1,18 +1,13 @@
|
||||
name: CI
|
||||
|
||||
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
|
||||
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
|
||||
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
|
||||
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
|
||||
# correct on a supported release and on GitHub Actions, but on this server they are inert:
|
||||
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
|
||||
#
|
||||
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
|
||||
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
|
||||
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
|
||||
# is upgraded and configured, the token's authority is whatever the server grants — not what
|
||||
# this file requests.
|
||||
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
|
||||
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
|
||||
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
|
||||
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
|
||||
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
|
||||
# this server is not yet proven, so nothing here relies on it being read-only.
|
||||
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
|
||||
# teardown relies on the stack sweep and the runner's own reaper.
|
||||
#
|
||||
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
||||
# itself. See docs/development/security.md.
|
||||
@@ -63,6 +58,9 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -72,13 +70,29 @@ jobs:
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
- name: Prove the sweep removes exactly what it should, against real Docker resources
|
||||
run: bash scripts/test-ci-sweep.sh
|
||||
|
||||
- name: Exercise PostgreSQL and S3 infrastructure
|
||||
run: bash scripts/smoke-infrastructure.sh
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
migration-smoke:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: migration-smoke
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -88,6 +102,11 @@ jobs:
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
@@ -103,3 +122,99 @@ jobs:
|
||||
|
||||
- name: Exercise transactional migrations against PostgreSQL
|
||||
run: pnpm test:migrations:integration
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
image-build:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: image-build
|
||||
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
||||
# has no buildx plugin.
|
||||
DOCKER_BUILDKIT: "1"
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
|
||||
# builds, validates, and assembles with zero modules. Build only — no registry login, no
|
||||
# credentials, no push, no tag anywhere but this runner's local image store.
|
||||
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
|
||||
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
|
||||
# Labeled like every CI stack, so this job's final step removes it even if the build is killed.
|
||||
- name: Build the production image without modules
|
||||
shell: bash
|
||||
run: |
|
||||
source scripts/lib/ci-project.sh
|
||||
docker build \
|
||||
--file deploy/Dockerfile \
|
||||
--build-arg REVISION="$GITHUB_SHA" \
|
||||
--label "${EKKLESIA_CI_LABEL}=$(ci_project_name image)" \
|
||||
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" \
|
||||
.
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
deploy-smoke:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which
|
||||
# adds a test-only module and drives a real headless browser across the two origins.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
topology:
|
||||
- same-origin
|
||||
- split
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
|
||||
DOCKER_BUILDKIT: "1"
|
||||
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
|
||||
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
||||
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
|
||||
# database TLS through the proxy. Every secret is generated for this run and discarded with
|
||||
# it; nothing is pushed, published, or logged in to.
|
||||
- name: Smoke-test the reference deployment (${{ matrix.topology }})
|
||||
run: bash scripts/smoke-deploy.sh
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
@@ -15,11 +15,11 @@ monorepo:
|
||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||
surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled
|
||||
`entrypoints.ui` artifact, authored in React per ADR 0022 against `@ekklesia/module-ui-sdk`. No
|
||||
host mounts that entrypoint yet, and `pnpm boundaries` still rejects every non-React UI framework
|
||||
and non-`.tsx` presentation source inside a module package.
|
||||
- **Surfaces** (future `surfaces/*`) own the containing application shell, navigation, and every
|
||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
||||
or module internals directly.
|
||||
|
||||
|
||||
+31
-9
@@ -9,7 +9,8 @@ Before opening a change, run:
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
Migration-runner changes also require the disposable live PostgreSQL suite:
|
||||
Migration-runner changes and changes to identity/authentication or the API's session transport also
|
||||
require the disposable live PostgreSQL suite:
|
||||
|
||||
```bash
|
||||
pnpm test:migrations:integration
|
||||
@@ -34,20 +35,41 @@ pnpm infrastructure:config
|
||||
private source paths.
|
||||
- Use `workspace:` ranges for internal package dependencies.
|
||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
||||
`pnpm boundaries`. UI source lives at `src/ui/index.tsx` and `pnpm build` compiles it to the
|
||||
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write
|
||||
no bundler configuration. Styling is React inline style objects only for now, and no host mounts a
|
||||
UI entrypoint yet.
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||
other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
|
||||
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
|
||||
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
|
||||
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
|
||||
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
|
||||
presentation-mode flag, and the same versioned mount ABI.
|
||||
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
|
||||
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
|
||||
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
|
||||
stays rejected inside a module. Reference registered semantic tokens; never define an
|
||||
`--ekklesia-*` custom property of your own.
|
||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
||||
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
||||
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||
private deployment detail and would break standalone hosting.
|
||||
- The containing application shell, navigation, global theme, and platform policy belong to Core and
|
||||
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
|
||||
installed module UIs through `GET /api/v1/module-ui` and mounts them with
|
||||
`@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026).
|
||||
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
||||
- A module may contribute a browser renderer to a capability another module provides
|
||||
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
|
||||
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
|
||||
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
|
||||
ordering. A contributed renderer gets its own id and that input, and nothing else.
|
||||
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
||||
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
||||
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
||||
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
|
||||
display-scoped service worker caches the shell and declared artifacts only, network-first, and
|
||||
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
|
||||
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
||||
module, child module, owning module, or group-derived dependency: real module relationships are
|
||||
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
||||
|
||||
@@ -11,18 +11,22 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas
|
||||
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
|
||||
against an independent security audit) is complete: all five passes have landed and the final
|
||||
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
||||
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** Phase 6 has since
|
||||
completed through 6D, and **Phase 7A passes 1 (the minimal operational module lifecycle), 2 (the module configuration and
|
||||
secrets contract), 3 (host-provided module object storage), and 4 (direct browser object delivery)
|
||||
have landed.** The operational
|
||||
not-yet-deployed threat model.** Phase 6 completed through 6D, and **Phase 7A passes 1 (the minimal
|
||||
operational module lifecycle), 2 (the
|
||||
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
|
||||
browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
|
||||
kiosk host) have landed. Phase 7B pass 1 adds the Dashboard's host-owned browser sign-in, sign-out,
|
||||
session-expiry handling, and authenticated-shell transition.** The operational
|
||||
requirements that remain are prerequisites for two specific *future* activities — executing
|
||||
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
||||
untrusted CI contributions, and a public production launch — and neither gates ongoing product
|
||||
development; see the
|
||||
pass 5 closure below and
|
||||
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes
|
||||
public module contracts, independently versioned capabilities, static module manifests,
|
||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||||
schema aggregation, and a cloneable backend-only module template. Migration artifacts have
|
||||
schema aggregation, and a cloneable module-product template with optional primary and display UI.
|
||||
Migration artifacts have
|
||||
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
|
||||
declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
|
||||
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
|
||||
@@ -48,10 +52,13 @@ 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
|
||||
[API](docs/development/api.md) and
|
||||
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
||||
**No email verification, password recovery, or Dashboard exists yet** — that, along with
|
||||
production approval-provider composition, jobs, physical storage provisioning, surfaces, and
|
||||
feature modules, remains later vertical slices. Public registration is not yet ready for a real
|
||||
public launch.
|
||||
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
|
||||
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,
|
||||
but not a finished product UI or a production asset-hosting deployment; jobs, production
|
||||
approval-provider composition, feature modules, and the public Website remain later slices.
|
||||
|
||||
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
|
||||
for platform owners, explicit grants, and relationship-derived grants; a smallest-possible
|
||||
@@ -64,7 +71,7 @@ accounts/API tokens remain later work. See [Authorization](docs/development/auth
|
||||
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
||||
|
||||
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
|
||||
(version 3 at the time, version 4 today) declares permissions as complete
|
||||
(version 3 at the time, version 6 today) declares permissions as complete
|
||||
`{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
|
||||
permission registry — Core's own declarations plus every module's — before constructing its
|
||||
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
|
||||
@@ -211,9 +218,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
|
||||
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
||||
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
||||
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
||||
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
|
||||
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
|
||||
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
|
||||
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
|
||||
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
|
||||
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
|
||||
disposable runner isolation. Those are
|
||||
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||||
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||||
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||||
@@ -233,15 +241,15 @@ never a dependency. The long-term goal is one module implementation hosted by Ek
|
||||
by a standalone application — never two codebases for one domain. See
|
||||
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
|
||||
|
||||
**What that pass shipped is the static contract only.** `manifestVersion: 4` adds one
|
||||
optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
|
||||
**What that pass shipped at its checkpoint was the static contract only.** `manifestVersion: 4`
|
||||
added one optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
|
||||
during discovery exactly like `server` and `schema` — inside the package, real file, no source
|
||||
TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly
|
||||
like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a
|
||||
one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was
|
||||
before it. **The Dashboard shell, a UI runtime or renderer, navigation
|
||||
registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading,
|
||||
and every feature module do not exist.** Restart-based discovery, explicit operator-invoked
|
||||
TypeScript. Discovery stayed inert: **nothing imported, mounted, or rendered that entrypoint**,
|
||||
exactly like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest was a
|
||||
one-character edit; version 3 was rejected outright rather than reinterpreted, as version 2 was
|
||||
before it. The Dashboard shell, runtime mounting, and navigation named as absent at that checkpoint
|
||||
landed in subsequent Phase 6 passes. A standalone host, module installer or marketplace, hot-loading,
|
||||
and every real feature module still do not exist. Restart-based discovery, explicit operator-invoked
|
||||
migrations, and the trusted compiled-module model are unchanged.
|
||||
|
||||
Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
|
||||
@@ -255,8 +263,9 @@ returns an unmount function. A host needs an element and a context, never a matc
|
||||
|
||||
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
|
||||
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
|
||||
host context is deliberately four members — `moduleId`, `api`, `principal`, `permissions` — and
|
||||
nothing else. **A module UI asks its host for its own API by relative path** (`api.request("posts")`)
|
||||
host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and
|
||||
`realtime` — and nothing else. **A module UI asks its host for its own API by relative path**
|
||||
(`api.request("posts")`)
|
||||
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
|
||||
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
|
||||
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
|
||||
@@ -281,7 +290,8 @@ artifacts cannot drift.
|
||||
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
||||
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
||||
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
||||
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
|
||||
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
|
||||
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
|
||||
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||||
and installing a module or starting the API never triggers one.
|
||||
|
||||
@@ -340,15 +350,17 @@ GET /api/v1/module-ui → which module UIs may be mounted, and where
|
||||
@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container
|
||||
↓
|
||||
context.api.request("records") → /api/v1/modules/example/records
|
||||
context.realtime.subscribe("state") → host-owned live state transport
|
||||
```
|
||||
|
||||
The new inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
|
||||
artifact URL, module API root, and the viewer's granted permissions **for that module** — and nothing
|
||||
else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because navigation metadata is a
|
||||
design that does not exist yet. It is authenticated (`core.modules.ui.read`, baseline
|
||||
The inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
|
||||
artifact URL, module API root, module realtime root, and the viewer's granted permissions **for that
|
||||
module** — and nothing else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because
|
||||
navigation metadata is a separate design. It is authenticated (`core.modules.ui.read`, baseline
|
||||
`authenticated`) precisely because it is the module enumeration the anonymous artifact route
|
||||
deliberately withholds. Both URLs are produced by the server's own canonical helpers, so the browser
|
||||
never rebuilds Ekklesia's module prefixes and either can change without touching host or module code.
|
||||
deliberately withholds. All three URLs are produced by the server's own canonical helpers, so the
|
||||
browser never rebuilds Ekklesia's module prefixes and any can change without touching host or module
|
||||
code.
|
||||
|
||||
**The shell statically imports no module.** Every module it can mount arrives at run time from the
|
||||
inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no
|
||||
@@ -477,7 +489,9 @@ start({ config }) {
|
||||
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
|
||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||
module code. That forces `manifestVersion: 5`, a hard cutover in this repository's usual style.
|
||||
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
|
||||
browser contributions.
|
||||
|
||||
**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
|
||||
@@ -594,16 +608,183 @@ and rewritten to another. The origin a browser uses is therefore a signing input
|
||||
(`S3_DELIVERY_ENDPOINT`), defaulting to `S3_ENDPOINT` — so the standard deployment configures nothing
|
||||
and delivery simply works. A reverse proxy in front of storage must pass the original `Host` through
|
||||
unchanged; it may terminate TLS, because the scheme is not signed. Buckets stay private: an unsigned
|
||||
request for the same object is refused, and a test asserts it. **Signed write and delete URLs, and a
|
||||
realtime refresh transport, are deliberately deferred.** See
|
||||
request for the same object is refused, and a test asserts it. **Signed write and delete URLs are
|
||||
deliberately deferred.** See
|
||||
[ADR 0031](docs/architecture/decisions/0031-direct-object-delivery.md).
|
||||
|
||||
Phase 7A pass 5 adds native, host-neutral module realtime
|
||||
([ADR 0032](docs/architecture/decisions/0032-native-module-realtime-transport.md)). During
|
||||
`register`, a module declares a small, module-local channel and the permission that protects it;
|
||||
during `start`, running work, and HTTP handlers, it publishes bounded JSON state through its own
|
||||
scoped publisher. The publisher is inert by the time that module's stop hook begins. Core keeps only
|
||||
the latest state and one pending update per subscriber. A module
|
||||
must publish an initial state before startup completes, so every accepted subscriber receives one
|
||||
atomic authoritative snapshot and then monotonically sequenced updates without a GET-then-subscribe
|
||||
race. Slow clients never block publishers and intermediate states may be coalesced.
|
||||
|
||||
Ekklesia carries that contract over permission-gated Server-Sent Events, with cookie authentication,
|
||||
15-second transport-owned keepalives, a five-minute reauthorization cycle, browser-native reconnect,
|
||||
and a fresh snapshot on every reconnection. The module UI API remains transport-neutral:
|
||||
|
||||
```ts
|
||||
const subscription = context.realtime.subscribe<SlideshowState>("state", {
|
||||
onSnapshot: (state) => render(state),
|
||||
onUpdate: (state) => render(state),
|
||||
});
|
||||
|
||||
// Return this from `mount`, or call it from your unmount cleanup.
|
||||
subscription.close();
|
||||
```
|
||||
|
||||
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
|
||||
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
|
||||
pass through realtime unchanged.
|
||||
|
||||
Phase 7A pass 6 adds the smallest complete **authenticated display/kiosk host**
|
||||
([ADR 0033](docs/architecture/decisions/0033-display-kiosk-host-contract.md)). Manifest version 6
|
||||
adds an optional, independently declared display role:
|
||||
|
||||
```json
|
||||
{
|
||||
"entrypoints": {
|
||||
"display": {
|
||||
"artifact": "./dist/display/index.js",
|
||||
"permission": "announcements.display.view"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The permission must be declared by that module and have an `authenticated` or `restricted` baseline. Primary UI and display UI are independently optional, must
|
||||
use paths distinct from every other entrypoint, and use the same
|
||||
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
|
||||
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
|
||||
`src/display/index.tsx`) and atomically emits every declared role.
|
||||
|
||||
Phase 7B pass 2 gives those screens an identity of their own
|
||||
([ADR 0036](docs/architecture/decisions/0036-managed-device-identity-and-pairing.md)), so a
|
||||
television no longer needs a person signed into it. Ekklesia now recognizes **three** principal
|
||||
classes — anonymous, authenticated human, and managed device:
|
||||
|
||||
```text
|
||||
screen opens /display/announcements → no identity → shows a 6-digit code
|
||||
administrator types the code in the Dashboard, names the screen, approves
|
||||
screen collects an HttpOnly credential → display and realtime work
|
||||
administrator revokes → the screen loses access and shows a code again
|
||||
```
|
||||
|
||||
**A device is not a user.** It has no email, no password, and no row in the identity tables, and it
|
||||
satisfies the `authenticated` baseline *never* — so every route that settles for "somebody is signed
|
||||
in" stays closed to a screen on a wall. Device authority runs through the same authorization
|
||||
evaluator as everyone else's, and approval grants exactly the one display permission the host derived
|
||||
from the module's own declaration. `/api/v1/principal` reports a device as anonymous on purpose: a
|
||||
device cookie cannot open the Dashboard.
|
||||
|
||||
**The code is not the credential.** Six digits are printed across a television in a public hallway,
|
||||
so they are treated as public: they identify a request to an administrator and yield nothing on their
|
||||
own. Collecting the credential also requires an HttpOnly claim cookie only the requesting browser
|
||||
holds, and approving requires the restricted `core.devices.manage` permission. The credential itself
|
||||
is stored only as a SHA-256 digest, never logged, never in a URL, and never readable by the module
|
||||
display code running in that same page.
|
||||
|
||||
Revocation is a server-side fact checked on every request, not a plea that a remote browser forget
|
||||
something; re-pairing creates a **new** identity so an old credential can never come back. A revoked
|
||||
screen fails its next request immediately, though an already-open realtime stream is bounded by the
|
||||
existing five-minute forced reconnect rather than closed instantly. **QR scanning, per-device content
|
||||
targeting, device groups, and mobile dashboards are deliberately deferred** — a future QR simply
|
||||
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
|
||||
identity at all.
|
||||
|
||||
Phase 7C pass 1 adds the platform's first **outbound email transport**
|
||||
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
|
||||
authenticate people and pair devices but could not send a single message, which is what blocked
|
||||
email verification, password recovery, and invitations alike. Email is now a host-provided
|
||||
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
|
||||
this transactional message* and the provider owns the host, port, TLS, credentials, and every
|
||||
transport error. **No provider's hostname appears anywhere in Core**, and a test asserts it —
|
||||
Google Workspace SMTP Relay is the expected first deployment, not a platform dependency.
|
||||
|
||||
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
|
||||
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
|
||||
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
|
||||
mail appears to come from. Control characters in addresses, subjects, and display names are refused
|
||||
rather than stripped, which is what closes header injection.
|
||||
|
||||
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
|
||||
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
|
||||
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
|
||||
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
|
||||
relay authorized by source IP has neither — and the password is a deployment secret that never
|
||||
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
|
||||
|
||||
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
|
||||
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
|
||||
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
|
||||
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.
|
||||
|
||||
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
|
||||
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
|
||||
while stating plainly that a successful connection does not establish deliverability. 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
|
||||
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
||||
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
|
||||
`GET /api/v1/module-displays/<module-id>`. The display surface is a separate full-viewport browser
|
||||
application with no Dashboard chrome, no static module imports, and manual retry on failure. Only
|
||||
eligible, running modules receive routes; manifest changes take effect on restart. Artifact bytes
|
||||
remain anonymous trusted code, while the page and descriptor use normal cookie authentication and
|
||||
the module-owned display permission. Realtime subscriptions and opaque signed object-delivery URLs
|
||||
flow through the unchanged host context. Display inventory/management, variants and targeting,
|
||||
public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the
|
||||
Announcements migration, and Companion integration remain deferred.
|
||||
|
||||
Phase 7B pass 1 adds **host-owned browser authentication to the Dashboard shell**
|
||||
([ADR 0034](docs/architecture/decisions/0034-host-owned-browser-authentication.md)). Anonymous
|
||||
bootstrap renders an accessible email/password form; successful submission uses the existing
|
||||
`POST /api/auth/sign-in/email` endpoint, whose successful browser-visible body is reduced to fixed
|
||||
`{"ok":true}` while the HttpOnly cookie remains authoritative. The shell resolves
|
||||
`GET /api/v1/principal` again before protected navigation and module UI, and sign-out uses the
|
||||
existing `POST /api/auth/sign-out` before the same principal re-resolution. No token is stored or
|
||||
passed to a module, no arbitrary callback/return URL exists, and leaving authenticated state
|
||||
unmounts the active module through the generic loader's normal cleanup.
|
||||
|
||||
The flow intentionally offers neither signup nor password recovery: production signup remains
|
||||
prohibited, no email sender exists, and the repository still lacks a supported way to create the
|
||||
initial production identity required by platform-owner bootstrap. Authentication throttling is
|
||||
enabled but in-memory and per API process. Host-only cookies, exact trusted origins, and Better
|
||||
Auth's origin/CSRF checks remain unchanged; production Dashboard DNS/TLS/proxy/CSP and built-asset
|
||||
hosting are deployment work, not guarantees added by this pass. Display device identity and the
|
||||
public Website remain separate future designs.
|
||||
|
||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||||
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||||
a CLI, not a screen, and object storage is deployment configuration. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond
|
||||
the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do
|
||||
not exist.
|
||||
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
||||
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||||
standalone host, and every real feature module also do not exist.
|
||||
|
||||
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
||||
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||||
@@ -614,6 +795,74 @@ unproven launch-blockers rather than existing guarantees. Read
|
||||
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
||||
module or exposing the API.
|
||||
|
||||
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
|
||||
None of them is a feature module, and none of them names one.
|
||||
|
||||
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
|
||||
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
|
||||
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
|
||||
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
|
||||
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
|
||||
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
|
||||
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
|
||||
badge, toast, and empty/error/loading states), every value a token reference, with real
|
||||
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
|
||||
exactly two shared roles to modules by name and pins both design packages beneath every other role.
|
||||
The Dashboard and display install the defaults at their document root; custom properties inherit into
|
||||
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
|
||||
theme editor, brand builder, second theme, or component catalogue exists.**
|
||||
|
||||
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
|
||||
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
|
||||
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
|
||||
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
|
||||
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
|
||||
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
|
||||
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
|
||||
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
|
||||
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
|
||||
is versioned with no dual acceptance; migrating is one `pnpm build`.
|
||||
|
||||
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||||
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
|
||||
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||||
means. Publication requires that another eligible module provides the capability inside the declared
|
||||
range; a contribution that fails is absent rather than broken. A consumer discovers through
|
||||
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
|
||||
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
|
||||
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
|
||||
database, storage, or credential — and loading one grants its module nothing.
|
||||
|
||||
Phase 8 pass 4 closes the object-storage write path
|
||||
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
|
||||
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
|
||||
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
|
||||
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
|
||||
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
|
||||
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
|
||||
oversize is refused before authorization, the buffered path now counts what arrived rather than
|
||||
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
|
||||
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
|
||||
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
|
||||
|
||||
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
|
||||
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
|
||||
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
|
||||
every contributed renderer that display mounted, network-first so a screen can never be trapped on
|
||||
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
|
||||
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
|
||||
stated rather than papered over — a display cannot render module content while offline, because
|
||||
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
|
||||
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
|
||||
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
|
||||
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
|
||||
the module's responsibility and do not exist yet.**
|
||||
|
||||
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
@@ -633,8 +882,18 @@ pnpm verify
|
||||
|
||||
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
|
||||
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
|
||||
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration
|
||||
state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
||||
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage and
|
||||
outbound-email integration state.
|
||||
|
||||
Doctor reads its host infrastructure configuration through the same
|
||||
`loadHostConfiguration(process.env)` the API executable uses, so both derive identical settings and
|
||||
identical defaults from one deployment environment: configured object storage is reported as
|
||||
configured, a module secret the deployment has set is reported as satisfied, and a value neither
|
||||
process could accept is reported as a configuration failure rather than silently interpreted one way
|
||||
by the diagnostic and another way at startup. An optional service that is genuinely absent — no
|
||||
`S3_ENDPOINT`, no `EKKLESIA_SMTP_HOST` — is still a complete deployment and reported as skipped.
|
||||
|
||||
`.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
||||
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
||||
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
|
||||
development infrastructure provides is only usable with it set.
|
||||
@@ -659,7 +918,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
5 permissions registered
|
||||
6 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
@@ -669,13 +928,14 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
6 migrations · checksums valid
|
||||
7 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
core:0002_authorization_integrity · 634294731180
|
||||
core:0003_dashboard_navigation_tables · 2cde27807aa4
|
||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||
core:0005_module_configuration · c8d57f24f84b
|
||||
core:0006_device_identity · 1db97b7e3ac5
|
||||
|
||||
PostgreSQL
|
||||
✓ Database TLS verified
|
||||
@@ -698,6 +958,10 @@ Object storage
|
||||
✓ No module requires object storage
|
||||
0 modules declare a logical storage bucket.
|
||||
|
||||
Outbound email
|
||||
○ Outbound email not configured
|
||||
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
|
||||
```
|
||||
|
||||
@@ -727,6 +991,44 @@ row, and start nothing — the change takes effect at the next `pnpm api:start`.
|
||||
that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by
|
||||
the process such a module takes down.
|
||||
|
||||
### Getting the first administrator into a fresh installation
|
||||
|
||||
A new installation has a login page and nothing to log in to: public registration is disabled by
|
||||
default and cannot be enabled in production at all. One explicit, operator-invoked command creates
|
||||
the initial identity and grants it platform ownership
|
||||
([ADR 0035](docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
|
||||
|
||||
```bash
|
||||
docker compose ... up -d # PostgreSQL and the rest of the stack
|
||||
pnpm migrations:apply # the schema must already exist
|
||||
pnpm identity:create-initial-owner -- --email [email protected]
|
||||
pnpm api:start # then sign in at the Dashboard login page
|
||||
```
|
||||
|
||||
It prompts for the password twice, invisibly, and never accepts one as a command-line argument —
|
||||
`--password` is refused, because an argument is visible in shell history and in the process list.
|
||||
For automated deployment it reads the password from standard input instead:
|
||||
|
||||
```bash
|
||||
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
|
||||
```
|
||||
|
||||
**No environment variable is read for the password and none belongs in `.env`**: it is used once to
|
||||
create an account, not kept as deployment configuration. The command needs `DATABASE_URL` and
|
||||
nothing else — no identity secret, no trusted origins, and no public base URL — so the first owner
|
||||
can be created on a freshly provisioned server before TLS or DNS exist.
|
||||
|
||||
It runs **once**. Ownership bootstrap is single-use, so a second run is refused: it creates no
|
||||
second owner, changes no existing password, and leaves the first owner untouched. It also never
|
||||
applies migrations — an unmigrated database is reported, not silently fixed. If it created the
|
||||
identity but failed before granting ownership, running the identical command again adopts that
|
||||
identity and finishes, leaving its password alone. Creating *additional* users and owners belongs to
|
||||
user administration, which does not exist yet.
|
||||
|
||||
This is not public signup and not a browser setup wizard: there is no `/signup`, `/register`, or
|
||||
`/setup` route, no unauthenticated bootstrap endpoint, and no setup token. The login page stays
|
||||
login-only.
|
||||
|
||||
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
|
||||
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
|
||||
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -21,18 +21,22 @@
|
||||
"dependencies": {
|
||||
"@ekklesia/contracts": "workspace:*",
|
||||
"@ekklesia/core": "workspace:*",
|
||||
"@ekklesia/shell": "workspace:*",
|
||||
"@hono/node-server": "2.0.10",
|
||||
"hono": "4.12.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/shell": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/smtp-server": "3.5.13",
|
||||
"better-auth": "1.6.23",
|
||||
"happy-dom": "20.0.11",
|
||||
"pg": "8.22.0",
|
||||
"smtp-server": "3.19.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
|
||||
+189
-12
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleSecretSource,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
@@ -9,27 +9,36 @@ import type {
|
||||
AuthorizationStore,
|
||||
PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleConfigurationSettings,
|
||||
ModuleConfigurationStore,
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleLifecycleStore,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleStorageLookup,
|
||||
ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDeviceRoutes } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||
|
||||
@@ -50,6 +59,9 @@ export interface CreateApiAppOptions {
|
||||
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
||||
* implicit fallback. */
|
||||
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
||||
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
|
||||
* empty, for the same reason as module routes: application composition never guesses what ran. */
|
||||
readonly moduleRealtime: ModuleRealtimeRegistry;
|
||||
/** The same frozen services object the caller's started module runtime already passed to every
|
||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
||||
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
||||
@@ -61,6 +73,21 @@ export interface CreateApiAppOptions {
|
||||
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
|
||||
* fall through to an implicit one. See `./module-ui-routes.js`. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
/**
|
||||
* Managed display devices. Optional so every existing composition — and every test that has no
|
||||
* interest in devices — builds unchanged; when it is absent no pairing route is mounted, no
|
||||
* device cookie is ever read, and a device principal simply cannot come into being.
|
||||
*/
|
||||
readonly devices?: {
|
||||
readonly store: DeviceStore;
|
||||
/** Whether the deployment is reached over https, so the device cookies can assert `Secure`
|
||||
* without becoming unusable on a plaintext development origin. */
|
||||
readonly secureCookies: boolean;
|
||||
};
|
||||
/** Surface-owned display host assets, read once by production startup. Optional only when the
|
||||
* eligible publication registry contains no display entrypoint; route composition rejects an
|
||||
* inconsistent display-bearing registry synchronously. */
|
||||
readonly displayHostAssets?: DisplayHostAssets;
|
||||
/**
|
||||
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
|
||||
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
|
||||
@@ -113,6 +140,20 @@ export interface CreateApiAppOptions {
|
||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||
* ordering, module startup, or any authorization decision. */
|
||||
readonly dashboard: DashboardStore;
|
||||
/**
|
||||
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
|
||||
* `./dashboard-host-routes.js`.
|
||||
*/
|
||||
readonly dashboardHostAssets: DashboardHostAssets;
|
||||
/**
|
||||
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
|
||||
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
|
||||
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
|
||||
*/
|
||||
readonly health: ApiHealthOptions;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||
@@ -127,6 +168,87 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
const EMAIL_SIGN_IN_PATH = "/api/auth/sign-in/email";
|
||||
const EMAIL_SIGN_IN_SUCCESS_BODY = JSON.stringify({ ok: true });
|
||||
|
||||
/**
|
||||
* Better Auth publishes general-purpose session-management endpoints even when an application has
|
||||
* no client for them. These four can return the raw bearer-equivalent session token with the
|
||||
* pinned configuration: get/list/update return a session record; change-password returns a newly
|
||||
* minted token when asked to revoke other sessions. Ekklesia deliberately exposes only its narrow
|
||||
* `/api/v1/principal` projection and has no native session/password-management browser UI, so the
|
||||
* supported method/path pairs are closed at the outer HTTP boundary before Better Auth executes.
|
||||
*/
|
||||
const TOKEN_BEARING_AUTH_ENDPOINT_METHODS: Readonly<Record<string, readonly string[]>> =
|
||||
Object.freeze({
|
||||
"/api/auth/change-password": Object.freeze(["POST"]),
|
||||
"/api/auth/get-session": Object.freeze(["GET", "POST"]),
|
||||
"/api/auth/list-sessions": Object.freeze(["GET"]),
|
||||
"/api/auth/update-session": Object.freeze(["POST"]),
|
||||
});
|
||||
|
||||
function decodedRequestPathname(request: Request): string | undefined {
|
||||
const encodedPathname = new URL(request.url).pathname;
|
||||
try {
|
||||
// URL.pathname intentionally preserves percent escapes. Decode exactly once before comparing,
|
||||
// matching the maximum decoding a downstream HTTP router may safely perform; malformed
|
||||
// encodings are denied rather than delegated through an ambiguous parser boundary.
|
||||
return decodeURIComponent(encodedPathname);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isDisabledTokenBearingAuthRequest(request: Request): boolean {
|
||||
const pathname = decodedRequestPathname(request);
|
||||
if (pathname === undefined) {
|
||||
return true;
|
||||
}
|
||||
// Better Auth is configured not to skip trailing slashes, but treating trailing-slash spellings
|
||||
// as the same denied resource prevents a router-option change from opening a bypass. This does
|
||||
// not collapse interior duplicate slashes or otherwise broaden path normalization.
|
||||
const canonicalPath = pathname.replace(/\/+$/u, "") || "/";
|
||||
return TOKEN_BEARING_AUTH_ENDPOINT_METHODS[canonicalPath]?.includes(request.method) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Better Auth's native successful email sign-in response includes the newly created session token
|
||||
* and the complete user object in its JSON body even though the browser already received the
|
||||
* session through an HttpOnly cookie. The browser boundary must never make either value visible to
|
||||
* application JavaScript. Keep this deliberately narrower than a general Better Auth response
|
||||
* adapter: only the exact supported POST endpoint and a body-bearing successful response are
|
||||
* normalized. Every sign-in failure and every other delegated auth endpoint remains Better Auth's
|
||||
* native contract; the explicitly denied token-bearing endpoints above are never delegated.
|
||||
*/
|
||||
function browserSafeAuthResponse(request: Request, response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "no-store");
|
||||
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
decodedRequestPathname(request) === EMAIL_SIGN_IN_PATH &&
|
||||
response.ok &&
|
||||
response.status !== 204 &&
|
||||
response.status !== 205
|
||||
) {
|
||||
// The upstream length describes the deliberately discarded token/user JSON. Retaining it could
|
||||
// truncate or stall the replacement payload once an HTTP adapter serializes this response.
|
||||
headers.delete("Content-Length");
|
||||
headers.set("Content-Type", "application/json; charset=UTF-8");
|
||||
return new Response(EMAIL_SIGN_IN_SUCCESS_BODY, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
|
||||
* dynamic-function `origin` — including one that already has its own `Vary` (module responses
|
||||
@@ -193,6 +315,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
app.use("/api/auth/*", corsMiddleware);
|
||||
app.use("/api/v1/*", corsMiddleware);
|
||||
|
||||
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
|
||||
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
|
||||
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
|
||||
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
|
||||
// policy to the response.
|
||||
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
|
||||
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
|
||||
return next();
|
||||
}
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "NOT_FOUND" }, 404);
|
||||
});
|
||||
|
||||
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
|
||||
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
|
||||
try {
|
||||
@@ -201,13 +336,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
|
||||
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
|
||||
// and never reads or buffers the body.
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "no-store");
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return browserSafeAuthResponse(c.req.raw, response);
|
||||
} catch (error) {
|
||||
return sanitizedErrorResponse(c, error, 500);
|
||||
}
|
||||
@@ -229,6 +358,16 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}
|
||||
});
|
||||
|
||||
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
|
||||
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
|
||||
// own entirely. See `./health-routes.js`.
|
||||
app.route("/", createHealthRoutes(options.health));
|
||||
|
||||
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||
// `./dashboard-host-routes.js`.
|
||||
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
@@ -259,6 +398,34 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
// A separate, chrome-free browser host. Exact page and descriptor routes are derived from the
|
||||
// same eligible/running publication registry as artifact bytes; a display-only module never
|
||||
// enters Dashboard navigation, and no request value can select a file or permission.
|
||||
mountModuleDisplayRoutes(app, {
|
||||
evaluator: options.authorization.evaluator,
|
||||
moduleUi: options.moduleUi,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||
});
|
||||
|
||||
if (options.devices) {
|
||||
// Host-owned device identity. Siblings of `/api/v1/modules/...`, reachable by no module, and
|
||||
// mounted only when the deployment actually composed device support.
|
||||
app.route(
|
||||
"/",
|
||||
createDeviceRoutes({
|
||||
devices: options.devices.store,
|
||||
evaluator: options.authorization.evaluator,
|
||||
moduleUi: options.moduleUi,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
secureCookies: options.devices.secureCookies,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Layered deliberately on top of the generic inventory above rather than merged into it: the
|
||||
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
|
||||
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
|
||||
@@ -310,11 +477,21 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
// A sibling platform-owned prefix, never nested under `/api/v1/modules/` where installed modules
|
||||
// own every path. One exact route per runtime-published channel, authorized before subscription.
|
||||
mountModuleRealtimeRoutes(app, {
|
||||
evaluator: options.authorization.evaluator,
|
||||
realtime: options.moduleRealtime,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
});
|
||||
|
||||
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
||||
configurations: options.moduleConfiguration.snapshots,
|
||||
storages: options.moduleStorage.snapshots,
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
realtime: options.moduleRealtime,
|
||||
runtime,
|
||||
services: options.moduleServices,
|
||||
trustedOrigins: allowedOrigins,
|
||||
|
||||
+187
-17
@@ -1,6 +1,10 @@
|
||||
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import {
|
||||
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||
type ModuleRouteBodyMode,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
|
||||
/**
|
||||
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
||||
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
|
||||
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
||||
* abuse.
|
||||
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
||||
* constant a module's own route declaration is validated against at registration) is the hard
|
||||
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
|
||||
* nothing in this platform ever declares, or enforces, a larger single request body than this.
|
||||
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
|
||||
* a future dedicated storage design, not to widening this ceiling.
|
||||
* constant a module's own **buffered** route declaration is validated against at registration) is
|
||||
* the ceiling for every body the platform materializes before a handler runs. It has not moved.
|
||||
* - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
|
||||
* explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
|
||||
* `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
|
||||
* single request body, and no unbounded option exists anywhere in the contract.
|
||||
*/
|
||||
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
||||
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
||||
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING };
|
||||
export {
|
||||
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||
};
|
||||
|
||||
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
||||
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
||||
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
|
||||
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
||||
}
|
||||
|
||||
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
|
||||
* contract rather than redefining `onError` per route. Covers both a declared oversized
|
||||
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
|
||||
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
|
||||
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
|
||||
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
|
||||
export function platformBodyLimit(maxSize: number) {
|
||||
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
|
||||
/**
|
||||
* A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
|
||||
*
|
||||
* A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
|
||||
* the byte counting below bounds the request either way, so refusing here could only ever turn a
|
||||
* legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
|
||||
* parser rejects genuinely malformed framing before this code sees the request at all.
|
||||
*/
|
||||
function declaredContentLength(request: Request): number | undefined {
|
||||
const header = request.headers.get("content-length");
|
||||
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
return Number.parseInt(header.trim(), 10);
|
||||
}
|
||||
|
||||
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
|
||||
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
|
||||
function releaseUnreadBody(request: Request): void {
|
||||
const body = request.body;
|
||||
if (!body || body.locked) {
|
||||
return;
|
||||
}
|
||||
void body.cancel().catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
|
||||
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
|
||||
* chunk past the limit rather than the whole upload.
|
||||
*/
|
||||
async function readBounded(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
maxSize: number,
|
||||
): Promise<readonly Uint8Array[] | "over-limit"> {
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
return chunks;
|
||||
}
|
||||
size += value.byteLength;
|
||||
if (size > maxSize) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return "over-limit";
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's one request-body limiter, for every route in the application.
|
||||
*
|
||||
* ## Both modes reject a declared oversize before anything else happens
|
||||
*
|
||||
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
|
||||
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
|
||||
* too-large upload takes.
|
||||
*
|
||||
* ## `buffered` — the body is materialized, bounded, before the handler runs
|
||||
*
|
||||
* The exact behaviour every route had before streaming existed, now with the counting done here
|
||||
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
|
||||
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
|
||||
* what actually arrived rather than by what was claimed. The handler then receives a request whose
|
||||
* body is fully available and provably within its declared allowance — which is what makes
|
||||
* `await request.json()` safe in a module handler.
|
||||
*
|
||||
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
|
||||
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
|
||||
*
|
||||
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
|
||||
*
|
||||
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
|
||||
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
|
||||
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
|
||||
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
|
||||
* is not a limit.
|
||||
*
|
||||
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
|
||||
* handler has started, so a handler that persists as it reads has to be prepared for a partially
|
||||
* consumed upload. Writing to a temporary key and committing only after the read completes is the
|
||||
* pattern; the platform cannot do it for you, because only the module knows what a partial write
|
||||
* means to its own data.
|
||||
*
|
||||
* ## Ordering
|
||||
*
|
||||
* This runs before permission evaluation for both modes, so a request that is *declared* too large
|
||||
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
|
||||
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
|
||||
* push a large upload through the platform at all.
|
||||
*
|
||||
* ## Timeouts
|
||||
*
|
||||
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
|
||||
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
|
||||
* platform already has one answer for that rather than a second per-route one.
|
||||
*/
|
||||
export function platformBodyLimit(
|
||||
maxSize: number,
|
||||
mode: ModuleRouteBodyMode = "buffered",
|
||||
): MiddlewareHandler {
|
||||
return async function enforceBodyLimit(c, next) {
|
||||
const request = c.req.raw;
|
||||
const declared = declaredContentLength(request);
|
||||
if (declared !== undefined && declared > maxSize) {
|
||||
return requestBodyTooLargeResponse(c);
|
||||
}
|
||||
if (!request.body) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (mode === "buffered") {
|
||||
const chunks = await readBounded(request.body, maxSize);
|
||||
if (chunks === "over-limit") {
|
||||
return requestBodyTooLargeResponse(c);
|
||||
}
|
||||
c.req.raw = new Request(request, {
|
||||
body: streamOf(chunks),
|
||||
// Required by undici whenever a `Request` is constructed from a stream.
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
return next();
|
||||
}
|
||||
|
||||
let overLimit = false;
|
||||
let total = 0;
|
||||
const counted = request.body.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
total += chunk.byteLength;
|
||||
if (total > maxSize) {
|
||||
overLimit = true;
|
||||
// Erroring the transform propagates back to the source and cancels it, so the socket is
|
||||
// released rather than left draining an upload nobody will read.
|
||||
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
}),
|
||||
);
|
||||
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
|
||||
|
||||
await next();
|
||||
|
||||
if (overLimit) {
|
||||
c.res = requestBodyTooLargeResponse(c);
|
||||
}
|
||||
releaseUnreadBody(c.req.raw);
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned
|
||||
* asset routes that answer `304` against a strong validator they computed from their own bytes.
|
||||
*/
|
||||
export function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||
if (headerValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (headerValue.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
return headerValue.split(",").some((candidate) => {
|
||||
const value = candidate.trim();
|
||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||
});
|
||||
}
|
||||
+44
-2
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
assertProductionDatabaseTransport,
|
||||
createDatabaseTransportPolicy,
|
||||
type DatabaseTransportPolicy,
|
||||
type DatabaseTransportPolicyInput,
|
||||
createDatabaseTransportPolicy,
|
||||
} from "@ekklesia/core/database";
|
||||
import {
|
||||
type CreateIdentityConfigOptions,
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
type IdentityConfig,
|
||||
type IdentityConfigInput,
|
||||
} from "@ekklesia/core/identity";
|
||||
import {
|
||||
createMailProviderConfig,
|
||||
type MailProviderConfig,
|
||||
type MailProviderConfigInput,
|
||||
} from "@ekklesia/core/mail";
|
||||
import {
|
||||
createStorageProviderConfig,
|
||||
type StorageProviderConfig,
|
||||
@@ -70,12 +75,24 @@ export interface ApiRuntimeConfig {
|
||||
* bundle, diagnostic, or API response ever sees the endpoint or the credentials.
|
||||
*/
|
||||
readonly storage: StorageProviderConfig | undefined;
|
||||
/**
|
||||
* The validated host outbound-email provider, or `undefined` when this installation has none
|
||||
* (ADR 0037).
|
||||
*
|
||||
* `undefined` is a valid deployment: the platform starts, every existing feature works, and the
|
||||
* 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
|
||||
* API response ever sees the host or the password.
|
||||
*/
|
||||
readonly mail: MailProviderConfig | undefined;
|
||||
}
|
||||
|
||||
export interface ApiRuntimeConfigInput {
|
||||
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
|
||||
* trust unless explicitly configured. See `./client-address.js`. */
|
||||
readonly clientAddress?: ClientAddressConfigInput;
|
||||
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||
readonly databaseUrl?: unknown;
|
||||
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
|
||||
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
|
||||
@@ -85,6 +102,8 @@ export interface ApiRuntimeConfigInput {
|
||||
readonly port?: unknown;
|
||||
/** Optional. Omitted, or with no endpoint, means this installation has no object storage. */
|
||||
readonly storage?: StorageProviderConfigInput;
|
||||
/** Optional. Omitted, or with no host, means this installation sends no outbound email. */
|
||||
readonly mail?: MailProviderConfigInput;
|
||||
}
|
||||
|
||||
export interface CreateApiRuntimeConfigOptions {
|
||||
@@ -164,13 +183,36 @@ export function createApiRuntimeConfig(
|
||||
// fails startup with a sanitized code rather than surfacing later as a confusing storage error.
|
||||
const storage = createStorageProviderConfig(input.storage, { environment });
|
||||
|
||||
// Validated in the same pass as everything else, so a malformed port, a half-supplied credential
|
||||
// pair, or a production deployment that asked for plaintext SMTP fails startup with a sanitized
|
||||
// code — rather than surfacing later as a confusing failure the first time something tries to
|
||||
// send a password-reset email.
|
||||
const mail = createMailProviderConfig(input.mail, { environment });
|
||||
|
||||
return Object.freeze({
|
||||
clientAddress,
|
||||
databaseTransport,
|
||||
databaseUrl,
|
||||
host,
|
||||
identity,
|
||||
mail,
|
||||
port,
|
||||
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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type DashboardDrizzleDatabase,
|
||||
} from "@ekklesia/core/dashboard";
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||
import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/core/devices";
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleConfigurationView,
|
||||
@@ -19,16 +20,37 @@ import {
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly devices: DeviceDrizzleDatabase;
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
readonly module: ModuleDatabase;
|
||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/**
|
||||
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
||||
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
||||
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
||||
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
||||
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
||||
* the pool discards that connection instead of returning it, so a transaction left open by a
|
||||
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
||||
* caller may surface it.
|
||||
*/
|
||||
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
function pingStatement(statementTimeoutMs: number): string {
|
||||
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
||||
// parameters. Only a positive safe integer can ever reach the SQL text.
|
||||
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
||||
throw new RangeError("The ping statement timeout must be a positive integer.");
|
||||
}
|
||||
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||
@@ -50,10 +72,14 @@ export function createApiDatabase(
|
||||
return Object.freeze({
|
||||
authorization: createAuthorizationDrizzleView(pool),
|
||||
close,
|
||||
devices: createDeviceDrizzleView(pool),
|
||||
dashboard: createDashboardDrizzleView(pool),
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleConfiguration: createModuleConfigurationView(pool),
|
||||
moduleLifecycle: createModuleLifecycleView(pool),
|
||||
ping: async (statementTimeoutMs: number) => {
|
||||
await pool.query(pingStatement(statementTimeoutMs));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createDevicePrincipal, type EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { DEVICE_LAST_SEEN_THROTTLE_MS } from "@ekklesia/core/devices";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { DEVICE_SESSION_COOKIE } from "./device-routes.js";
|
||||
|
||||
/**
|
||||
* Extends principal resolution with managed devices, without touching how a *person* is resolved
|
||||
* (ADR 0036).
|
||||
*
|
||||
* Composition rather than modification is deliberate. Core's identity runtime is the Better Auth
|
||||
* boundary and knows only about human sessions; teaching it about devices would put two unrelated
|
||||
* credential systems behind one implementation and make "which one answered?" an internal detail.
|
||||
* Wrapping keeps Better Auth's behaviour byte-for-byte unchanged — including that an infrastructure
|
||||
* failure still throws rather than degrading to anonymous — and adds exactly one fallback.
|
||||
*
|
||||
* **A human session always wins.** If Better Auth resolves a person, that is the answer and the
|
||||
* device cookie is never even read. This matters because a browser can hold both: an administrator
|
||||
* who opens a display page on their own laptop after pairing it stays themself, and no device
|
||||
* cookie can dilute or replace a human identity.
|
||||
*
|
||||
* Because every gate in the platform — the display page, the descriptor, module HTTP routes,
|
||||
* realtime — resolves principals through this one function, a device becomes a principal in exactly
|
||||
* one place, and every authorization decision about it is still made by the ordinary evaluator.
|
||||
*/
|
||||
export interface DevicePrincipalOptions {
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
readonly devices: DeviceStore;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
/** RFC 6265 `cookie-string` parsing, matching how Core's own session-cookie detection reads a
|
||||
* header: split on `;`, take the text before the first `=` as the name, compare whole names
|
||||
* exactly. Never a substring test, so `x_ekklesia_device_session` is correctly not a match. */
|
||||
function readCookie(headers: Headers, name: string): string | undefined {
|
||||
const header = headers.get("cookie");
|
||||
if (header === null) {
|
||||
return undefined;
|
||||
}
|
||||
for (const pair of header.split(";")) {
|
||||
const separator = pair.indexOf("=");
|
||||
if (separator === -1) {
|
||||
continue;
|
||||
}
|
||||
if (pair.slice(0, separator).trim() === name) {
|
||||
const value = pair.slice(separator + 1).trim();
|
||||
return value.length === 0 ? undefined : value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function withDevicePrincipals(options: DevicePrincipalOptions): EkklesiaIdentityRuntime {
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
|
||||
return Object.freeze({
|
||||
handleAuthRequest: (request: Request): Promise<Response> =>
|
||||
options.runtime.handleAuthRequest(request),
|
||||
|
||||
async resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal> {
|
||||
// Unchanged, including its failure behaviour: a genuine outage still throws rather than
|
||||
// silently letting a device credential answer a question about a person.
|
||||
const human = await options.runtime.resolvePrincipal(headers);
|
||||
if (human.kind !== "anonymous") {
|
||||
return human;
|
||||
}
|
||||
|
||||
const credential = readCookie(headers, DEVICE_SESSION_COOKIE);
|
||||
if (credential === undefined) {
|
||||
return human;
|
||||
}
|
||||
|
||||
const at = now();
|
||||
const device = await options.devices.resolveCredential(credential, at);
|
||||
if (!device) {
|
||||
// A revoked, expired, or simply wrong credential is anonymous — never an error the caller
|
||||
// could use to tell those three apart.
|
||||
return human;
|
||||
}
|
||||
|
||||
// Throttled here as well as in SQL: the common case must not even issue a statement, or a
|
||||
// display polling realtime would turn an operational nicety into continuous write traffic.
|
||||
const lastSeen = device.lastSeenAt;
|
||||
if (
|
||||
lastSeen === undefined ||
|
||||
at.getTime() - lastSeen.getTime() >= DEVICE_LAST_SEEN_THROTTLE_MS
|
||||
) {
|
||||
await options.devices.touchLastSeen(device.deviceId, at).catch(() => undefined);
|
||||
}
|
||||
|
||||
return createDevicePrincipal(device.deviceId);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
|
||||
import { CORE_DEVICES_MANAGE } from "@ekklesia/core/authorization";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { DEVICE_CREDENTIAL_TTL_MS } from "@ekklesia/core/devices";
|
||||
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { Hono as HonoApp } from "hono";
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
|
||||
/**
|
||||
* Host-owned device identity transport: pairing a display, and managing what has been paired
|
||||
* (ADR 0036).
|
||||
*
|
||||
* Both prefixes are siblings of `/api/v1/modules/...` and cannot collide with any module route.
|
||||
* Nothing here is reachable by module code, and no module-facing contract mentions any of it — a
|
||||
* module receives a principal and its permissions, and never learns that pairing exists.
|
||||
*/
|
||||
export const DEVICE_PAIRING_PREFIX = "/api/v1/device-pairing";
|
||||
export const DEVICES_PREFIX = "/api/v1/devices";
|
||||
|
||||
/**
|
||||
* The device's two cookies, deliberately named nothing like Better Auth's session cookie.
|
||||
*
|
||||
* Distinct names are what keep the two identity systems from being confused for one another: the
|
||||
* human resolver looks only for Better Auth's cookie and the device resolver looks only for these,
|
||||
* so neither can ever be satisfied by the other's credential no matter what a caller sends.
|
||||
*/
|
||||
export const DEVICE_PAIRING_COOKIE = "ekklesia_device_pairing";
|
||||
export const DEVICE_SESSION_COOKIE = "ekklesia_device_session";
|
||||
|
||||
/** How often an unpaired screen should ask again. Slow enough that a waiting display is not a load
|
||||
* source, fast enough that someone standing at the screen sees it come alive. */
|
||||
export const DEVICE_PAIRING_POLL_INTERVAL_MS = 3_000;
|
||||
|
||||
export interface DeviceRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly devices: DeviceStore;
|
||||
/** The same eligible/running publication registry the display routes use. It is the only source
|
||||
* of the permission a pairing request may ask for. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
/** True when the platform is reached over https, which is what `Secure` requires to be usable at
|
||||
* all. Derived from the validated identity base URL, never from a request. */
|
||||
readonly secureCookies: boolean;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
function noStore(c: Context): void {
|
||||
c.header("Cache-Control", "no-store");
|
||||
}
|
||||
|
||||
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
|
||||
noStore(c);
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
async function readJsonObject(c: Context): Promise<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
const body: unknown = await c.req.json();
|
||||
return typeof body === "object" && body !== null && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(body: Record<string, unknown>, key: string, max: number): string | undefined {
|
||||
const value = body[key];
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 || trimmed.length > max ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one of the device cookies.
|
||||
*
|
||||
* `HttpOnly` so no script — least of all a module's display bundle, which runs in this very page —
|
||||
* can read the value; `SameSite=Strict` because a display only ever talks to the host that serves
|
||||
* it and nothing legitimate arrives cross-site; host-only (no `Domain`) so a sibling hostname
|
||||
* cannot claim it; `Path=/` because the credential is presented to the descriptor, the module API,
|
||||
* and realtime alike. `Secure` follows the deployment's own scheme — asserting it on a plaintext
|
||||
* development origin would make the cookie silently unusable rather than more secure.
|
||||
*/
|
||||
function writeDeviceCookie(
|
||||
c: Context,
|
||||
options: DeviceRoutesOptions,
|
||||
name: string,
|
||||
value: string,
|
||||
maxAgeSeconds: number,
|
||||
): void {
|
||||
setCookie(c, name, value, {
|
||||
httpOnly: true,
|
||||
maxAge: maxAgeSeconds,
|
||||
path: "/",
|
||||
sameSite: "Strict",
|
||||
secure: options.secureCookies,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
const routes = new HonoApp();
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
const requireManage = requirePermission(options, CORE_DEVICES_MANAGE);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
|
||||
/**
|
||||
* An unpaired display asks to be paired.
|
||||
*
|
||||
* The **only** thing the caller supplies is which module's display it is sitting on, and even
|
||||
* that is not taken at its word: the module is looked up in the publication registry and the
|
||||
* permission is read from that module's validated display declaration. A browser therefore cannot
|
||||
* ask to be granted `core.devices.manage`, another module's permission, or anything else — the
|
||||
* request records the permission the host derived, and approval grants exactly that.
|
||||
*/
|
||||
routes.post(
|
||||
`${DEVICE_PAIRING_PREFIX}/requests`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
async (c: Context) => {
|
||||
const body = await readJsonObject(c);
|
||||
const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128);
|
||||
if (moduleId === undefined) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const display = options.moduleUi
|
||||
.listDisplays()
|
||||
.find((candidate) => candidate.moduleId === moduleId);
|
||||
if (!display) {
|
||||
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
|
||||
// pairing never becomes a way to enumerate what is installed.
|
||||
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||
}
|
||||
|
||||
try {
|
||||
const at = now();
|
||||
const created = await options.devices.createPairingRequest({
|
||||
moduleId: display.moduleId,
|
||||
now: at,
|
||||
permissionId: display.displayPermission,
|
||||
});
|
||||
writeDeviceCookie(
|
||||
c,
|
||||
options,
|
||||
DEVICE_PAIRING_COOKIE,
|
||||
created.claimSecret,
|
||||
Math.max(1, Math.ceil((created.expiresAt.getTime() - at.getTime()) / 1000)),
|
||||
);
|
||||
noStore(c);
|
||||
// The code is returned because it has to be shown on the screen. The claim secret is not:
|
||||
// it goes only into the HttpOnly cookie above, so reading the code off a television tells
|
||||
// an onlooker nothing they could use.
|
||||
return c.json(
|
||||
{
|
||||
code: created.code,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
pollIntervalMs: DEVICE_PAIRING_POLL_INTERVAL_MS,
|
||||
},
|
||||
201,
|
||||
);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The display asks whether it has been approved yet, and collects its credential when it has.
|
||||
*
|
||||
* Authority here is the claim cookie and nothing else. The pairing code is not accepted — it is
|
||||
* public, printed across a screen, and a flow that issued a credential to whoever presented it
|
||||
* would hand the device's identity to anyone who walked past. Consumption is single-use inside
|
||||
* the store, so a replayed or concurrent claim never yields a second credential.
|
||||
*/
|
||||
routes.post(
|
||||
`${DEVICE_PAIRING_PREFIX}/claims`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
async (c: Context) => {
|
||||
const claimSecret = getCookie(c, DEVICE_PAIRING_COOKIE);
|
||||
if (claimSecret === undefined || claimSecret.length === 0) {
|
||||
return sanitizedFailure(c, 400, "DEVICE_PAIRING_UNKNOWN");
|
||||
}
|
||||
try {
|
||||
const at = now();
|
||||
const result = await options.devices.claimPairingRequest({ claimSecret, now: at });
|
||||
noStore(c);
|
||||
if (result.paired) {
|
||||
writeDeviceCookie(
|
||||
c,
|
||||
options,
|
||||
DEVICE_SESSION_COOKIE,
|
||||
result.paired.credential,
|
||||
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||
);
|
||||
// The pairing cookie has done its job; leaving it would keep a spent secret on the device.
|
||||
writeDeviceCookie(c, options, DEVICE_PAIRING_COOKIE, "", 0);
|
||||
// Deliberately no credential, device id, or permission in the body: the browser needs to
|
||||
// know only that it may now load the display, and everything it needs travels in the
|
||||
// Set-Cookie header where script cannot reach it.
|
||||
return c.json({ status: "paired" as const }, 200);
|
||||
}
|
||||
return c.json({ status: result.status }, 200);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* An administrator types the code from the screen and sees what it is asking for, before deciding.
|
||||
*
|
||||
* The code travels in a request body rather than a path or query so it does not land in access
|
||||
* logs, browser history, or a `Referer`. Reaching this at all requires `core.devices.manage`, so
|
||||
* it is not an enumeration oracle for anyone who is not already trusted to approve pairings.
|
||||
*/
|
||||
routes.post(
|
||||
`${DEVICE_PAIRING_PREFIX}/lookups`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c: Context) => {
|
||||
const body = await readJsonObject(c);
|
||||
const code = body === undefined ? undefined : readString(body, "code", 16);
|
||||
if (code === undefined || !/^[0-9]{6}$/u.test(code)) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
try {
|
||||
const found = await options.devices.findPendingByCode(code, now());
|
||||
if (!found) {
|
||||
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
|
||||
}
|
||||
noStore(c);
|
||||
return c.json(
|
||||
{
|
||||
expiresAt: found.expiresAt.toISOString(),
|
||||
moduleId: found.moduleId,
|
||||
permissionId: found.permissionId,
|
||||
requestedAt: found.createdAt.toISOString(),
|
||||
},
|
||||
200,
|
||||
);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Approval. The administrator names the device; the unpaired screen never names itself. */
|
||||
routes.post(
|
||||
`${DEVICE_PAIRING_PREFIX}/approvals`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c: Context) => {
|
||||
const body = await readJsonObject(c);
|
||||
const code = body === undefined ? undefined : readString(body, "code", 16);
|
||||
const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128);
|
||||
if (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === undefined) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (principal.kind !== "authenticated") {
|
||||
// Unreachable through `requireManage`, which only ever admits a person. Stated rather than
|
||||
// assumed, so a device could never be recorded as having approved its own pairing.
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
try {
|
||||
const approved = await options.devices.approvePairingRequest({
|
||||
approvedBy: principal.userId,
|
||||
code,
|
||||
deviceName,
|
||||
now: now(),
|
||||
});
|
||||
if (!approved) {
|
||||
// Expired, already approved, already consumed, or never existed. One answer for all of
|
||||
// them: which one it was is not information this endpoint owes anybody.
|
||||
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
|
||||
}
|
||||
noStore(c);
|
||||
return c.json({ moduleId: approved.moduleId, permissionId: approved.permissionId }, 200);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
||||
try {
|
||||
const devices = await options.devices.listDevices();
|
||||
noStore(c);
|
||||
return c.json(
|
||||
{
|
||||
devices: devices.map((device) => ({
|
||||
createdAt: device.createdAt.toISOString(),
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
permissions: device.permissions,
|
||||
revoked: device.revokedAt !== undefined,
|
||||
...(device.lastSeenAt === undefined
|
||||
? {}
|
||||
: { lastSeenAt: device.lastSeenAt.toISOString() }),
|
||||
...(device.revokedAt === undefined
|
||||
? {}
|
||||
: { revokedAt: device.revokedAt.toISOString() }),
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Revocation, which is a server-side fact rather than a request that the remote browser discard
|
||||
* anything. The device's own copy of its credential keeps existing and stops working.
|
||||
*/
|
||||
routes.post(
|
||||
`${DEVICES_PREFIX}/:deviceId/revoke`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c: Context) => {
|
||||
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
|
||||
if (!parsed.success) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (principal.kind !== "authenticated") {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
try {
|
||||
await options.devices.revokeDevice({
|
||||
deviceId: parsed.data,
|
||||
now: now(),
|
||||
revokedBy: principal.userId,
|
||||
});
|
||||
} catch {
|
||||
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||
}
|
||||
noStore(c);
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||
|
||||
/**
|
||||
* The three immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||
*
|
||||
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
|
||||
* genuinely immutable value that every response can reuse safely.
|
||||
*/
|
||||
export interface DisplayHostAssets {
|
||||
readonly html: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
|
||||
* at a path whose directory is its scope so it controls display pages and nothing else. */
|
||||
readonly serviceWorker: string;
|
||||
readonly serviceWorkerEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
* location, and machine path are intentionally not retained or surfaced. */
|
||||
export class DisplayHostAssetError extends Error {
|
||||
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
|
||||
|
||||
constructor() {
|
||||
super("The display host browser assets are unavailable.");
|
||||
this.name = "DisplayHostAssetError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures one immutable, internally consistent display-host asset snapshot.
|
||||
*
|
||||
* The strong validator is always derived from the exact script text in the same snapshot; callers
|
||||
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||
* failed host build rather than a valid page that can never mount anything.
|
||||
*/
|
||||
export function createDisplayHostAssets(
|
||||
html: string,
|
||||
script: string,
|
||||
serviceWorker: string,
|
||||
): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
serviceWorker,
|
||||
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
|
||||
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
try {
|
||||
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
|
||||
// rejected sibling promise is left running after this function has already failed.
|
||||
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
||||
return createDisplayHostAssets(html, script, serviceWorker);
|
||||
} catch {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
/**
|
||||
* The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short
|
||||
* enough for a Compose health check or an external monitor to treat a stall as "not ready" rather
|
||||
* than as a hung probe, and well inside the shared pool's own 10s connection and operation
|
||||
* timeouts, which bound the underlying query independently.
|
||||
*/
|
||||
export const READINESS_DATABASE_TIMEOUT_MS = 2_000;
|
||||
|
||||
export const HEALTH_LIVE_PATH = "/api/v1/health/live";
|
||||
export const HEALTH_READY_PATH = "/api/v1/health/ready";
|
||||
|
||||
export interface ApiHealthOptions {
|
||||
/**
|
||||
* One trivial database round trip. Resolves when the database answered; rejects on any failure.
|
||||
* The rejection is never read — not its message, code, or cause — so nothing a driver says can
|
||||
* reach a response. The route bounds the wait and never overlaps two calls; this function only
|
||||
* has to perform the query.
|
||||
*/
|
||||
readonly checkDatabase: () => Promise<void>;
|
||||
/** Whether module startup has completed. `false` again from the moment shutdown begins, so a
|
||||
* draining instance stops reporting ready while it finishes in-flight requests. */
|
||||
readonly isModuleStartupComplete: () => boolean;
|
||||
}
|
||||
|
||||
const LIVE_BODY = Object.freeze({ status: "live" });
|
||||
const READY_BODY = Object.freeze({ status: "ready" });
|
||||
const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" });
|
||||
|
||||
/**
|
||||
* Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than
|
||||
* `timeoutMs`.
|
||||
*
|
||||
* Every probe arriving while a check is unsettled shares that check's outcome, including after its
|
||||
* deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes
|
||||
* answer immediately. A new check starts only once the previous call has actually settled. A timed-
|
||||
* out query still occupies a pool connection until the database or the pool's own timeouts end it,
|
||||
* so starting a fresh query on every probe would let a stalled database drain the pool one health
|
||||
* check at a time.
|
||||
*/
|
||||
function createBoundedDatabaseCheck(
|
||||
checkDatabase: () => Promise<void>,
|
||||
timeoutMs: number,
|
||||
): () => Promise<boolean> {
|
||||
let inFlight: Promise<boolean> | undefined;
|
||||
|
||||
return () => {
|
||||
if (inFlight !== undefined) {
|
||||
return inFlight;
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like
|
||||
// any other rather than an exception escaping the route.
|
||||
const settled = new Promise<void>((resolve) => resolve(checkDatabase())).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const outcome = Promise.race([
|
||||
settled,
|
||||
new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
inFlight = outcome;
|
||||
void settled.then(() => {
|
||||
clearTimeout(timer);
|
||||
inFlight = undefined;
|
||||
});
|
||||
return outcome;
|
||||
};
|
||||
}
|
||||
|
||||
/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD,
|
||||
* and these routes accept GET alone. */
|
||||
function methodNotAllowed(): Response {
|
||||
return new Response(null, {
|
||||
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
||||
status: 405,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external
|
||||
* monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies
|
||||
* that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed
|
||||
* operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that
|
||||
* installed modules own entirely, so no module route can shadow them.
|
||||
*/
|
||||
export function createHealthRoutes(options: ApiHealthOptions): Hono {
|
||||
const checkDatabase = createBoundedDatabaseCheck(
|
||||
options.checkDatabase,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
);
|
||||
const routes = new Hono();
|
||||
|
||||
routes.all(HEALTH_LIVE_PATH, (c) => {
|
||||
if (c.req.method !== "GET") {
|
||||
return methodNotAllowed();
|
||||
}
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json(LIVE_BODY, 200);
|
||||
});
|
||||
|
||||
routes.all(HEALTH_READY_PATH, async (c) => {
|
||||
if (c.req.method !== "GET") {
|
||||
return methodNotAllowed();
|
||||
}
|
||||
c.header("Cache-Control", "no-store");
|
||||
if (!options.isModuleStartupComplete()) {
|
||||
return c.json(NOT_READY_BODY, 503);
|
||||
}
|
||||
const databaseReady = await checkDatabase();
|
||||
// Checked again afterwards: shutdown may have begun while the query was in flight.
|
||||
return databaseReady && options.isModuleStartupComplete()
|
||||
? c.json(READY_BODY, 200)
|
||||
: c.json(NOT_READY_BODY, 503);
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Canonical browser routes owned by the platform's display host.
|
||||
*
|
||||
* Kept separate from the route registration module so both the display host and module-UI
|
||||
* inventory can publish the same path without introducing a dependency cycle.
|
||||
*/
|
||||
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||
|
||||
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||
import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||
export {
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
} from "./module-display-path.js";
|
||||
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||
import {
|
||||
describeModuleContributions,
|
||||
grantedModulePermissionIds,
|
||||
type HostModuleContributionDescriptor,
|
||||
} from "./module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||
|
||||
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
|
||||
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||
/**
|
||||
* Where the display's offline service worker is served (ADR 0043).
|
||||
*
|
||||
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
|
||||
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
|
||||
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
|
||||
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||
*
|
||||
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
|
||||
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
|
||||
*/
|
||||
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
|
||||
|
||||
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
|
||||
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
|
||||
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
|
||||
* boundary. */
|
||||
export interface HostModuleDisplayDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly display: { readonly url: string };
|
||||
/** The public page a screen opens to mount this display. This is distinct from `display.url`,
|
||||
* which is the compiled artifact imported by the platform host. */
|
||||
readonly page: { readonly url: string };
|
||||
readonly api: { readonly baseUrl: string };
|
||||
readonly realtime: { readonly baseUrl: string };
|
||||
readonly permissions: readonly string[];
|
||||
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
|
||||
* mechanism a signage-style display uses to show another module's content without importing it. */
|
||||
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the host resolved as the viewer of this display.
|
||||
*
|
||||
* The descriptor reports it because the display host must not ask `/api/v1/principal` — that
|
||||
* endpoint answers a question about *people*, and a paired device is deliberately anonymous to it
|
||||
* (ADR 0036). Reading the viewer from the response that already performed the authorization means
|
||||
* the display never has to infer "no human session, so presumably a device", and the two identity
|
||||
* classes never have to be told apart by elimination.
|
||||
*/
|
||||
export type HostDisplayViewer =
|
||||
| { readonly kind: "authenticated"; readonly userId: string }
|
||||
| { readonly kind: "device"; readonly deviceId: string };
|
||||
|
||||
export interface HostModuleDisplayResource {
|
||||
readonly module: HostModuleDisplayDescriptor;
|
||||
readonly viewer: HostDisplayViewer;
|
||||
}
|
||||
|
||||
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The same immutable, eligible/running publication registry used for artifact bytes. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||
* surface-owned files before binding and supplies their frozen contents here. */
|
||||
readonly assets?: DisplayHostAssets;
|
||||
}
|
||||
|
||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||
* permission id, path, or package location. */
|
||||
export class ModuleDisplayConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("The module display host configuration is inconsistent.");
|
||||
this.name = "ModuleDisplayConfigurationError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function displayFailure(c: Context): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||
}
|
||||
|
||||
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||
* bytes with a stale validator. */
|
||||
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||
try {
|
||||
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||
if (
|
||||
snapshot.scriptEtag !== input.scriptEtag ||
|
||||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||
) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
}
|
||||
|
||||
function describeViewer(principal: EkklesiaPrincipal): HostDisplayViewer | undefined {
|
||||
if (principal.kind === "authenticated") {
|
||||
return { kind: "authenticated", userId: principal.userId };
|
||||
}
|
||||
if (principal.kind === "device") {
|
||||
return { deviceId: principal.deviceId, kind: "device" };
|
||||
}
|
||||
// Unreachable behind the permission gate: the display permission is `restricted`, which no
|
||||
// anonymous principal satisfies. Stated rather than assumed.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function describeDisplay(
|
||||
options: ModuleDisplayRoutesOptions,
|
||||
principal: EkklesiaPrincipal,
|
||||
display: PublishableModuleDisplay,
|
||||
viewer: HostDisplayViewer,
|
||||
): Promise<HostModuleDisplayResource> {
|
||||
return {
|
||||
viewer,
|
||||
module: {
|
||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||
id: display.moduleId,
|
||||
page: { url: moduleDisplayPageUrl(display.moduleId) },
|
||||
permissions: await grantedModulePermissionIds(
|
||||
options.evaluator,
|
||||
principal,
|
||||
display.permissions,
|
||||
),
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) },
|
||||
version: display.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountDisplay(
|
||||
app: Hono,
|
||||
options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets },
|
||||
display: PublishableModuleDisplay,
|
||||
): void {
|
||||
// The permission comes from an already-validated manifest/publication record. A request selects
|
||||
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
|
||||
const requireDisplayPermission = requirePermission(options, display.displayPermission);
|
||||
|
||||
/**
|
||||
* The page itself is **not** permission-gated, and the descriptor below still is.
|
||||
*
|
||||
* An unattended screen has no identity until someone pairs it, so a gate here would mean a new
|
||||
* television could only ever be told 403 — there would be nowhere for it to display a pairing
|
||||
* code, and the only way to adopt one would be to sign a human into it, which is exactly the
|
||||
* model this pass exists to remove (ADR 0036).
|
||||
*
|
||||
* Serving it costs nothing, because it carries nothing: these are the same inert, module-agnostic
|
||||
* bytes for every display, with no module id, no data, and one absolute script URL that was
|
||||
* already public. Authorization lives entirely in the descriptor — the page can render, and then
|
||||
* learns it may show only a pairing code. What this does reveal is that a module with a display
|
||||
* exists at that path, which any pairing flow must reveal to the device it is pairing.
|
||||
*/
|
||||
app.get(moduleDisplayPageUrl(display.moduleId), () => {
|
||||
return new Response(options.assets.html, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
try {
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
const viewer = describeViewer(principal);
|
||||
if (!viewer) {
|
||||
return displayFailure(c);
|
||||
}
|
||||
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||
} catch {
|
||||
return displayFailure(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts one stable page and one descriptor route per eligible, running display declaration.
|
||||
*
|
||||
* There is deliberately no display inventory in this pass. A management UI may need one later,
|
||||
* but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only,
|
||||
* disabled, and dependency-blocked modules have no registered route and collapse into the
|
||||
* application's ordinary sanitized 404.
|
||||
*
|
||||
* The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute
|
||||
* script URL cannot be rebased by a deep `/display/<id>` path. The browser reads the validated id
|
||||
* from its outer URL, requests the permission-gated descriptor, and only then imports the exact
|
||||
* versioned display artifact that the server supplied.
|
||||
*/
|
||||
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
|
||||
const displays = options.moduleUi.listDisplays();
|
||||
if (displays.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!options.assets) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
const assets = snapshotAssets(options.assets);
|
||||
for (const display of displays) {
|
||||
if (!options.registry.has(display.displayPermission)) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
}
|
||||
|
||||
// One fixed host bundle, never a directory and never a request-selected package file. Like module
|
||||
// artifact bytes, fetching this platform code grants no authority; the page and descriptor are
|
||||
// where identity and the module-owned display permission are enforced.
|
||||
app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => {
|
||||
if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) {
|
||||
return new Response(null, {
|
||||
headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
return new Response(assets.script, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/javascript; charset=utf-8",
|
||||
ETag: assets.scriptEtag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
|
||||
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
|
||||
* how a screen avoids being trapped on an obsolete worker.
|
||||
*
|
||||
* Like the host script and module artifact bytes, fetching this code grants no authority. It
|
||||
* caches only public code and never a permission decision, so serving it anonymously withholds
|
||||
* nothing the platform was protecting.
|
||||
*/
|
||||
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
|
||||
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
|
||||
return new Response(null, {
|
||||
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
return new Response(assets.serviceWorker, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/javascript; charset=utf-8",
|
||||
ETag: assets.serviceWorkerEtag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
for (const display of displays) {
|
||||
mountDisplay(app, { ...options, assets }, display);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ModuleConfigurationLookup, ModuleStorageLookup } from "@ekklesia/core/modules";
|
||||
import type {
|
||||
ModuleConfigurationLookup,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleStorageLookup,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
@@ -53,6 +57,8 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
||||
* its own module's storage, because that is the only entry looked up for it.
|
||||
*/
|
||||
readonly storages: ModuleStorageLookup;
|
||||
/** The same started registry whose scoped publishers lifecycle hooks receive. */
|
||||
readonly realtime: ModuleRealtimeRegistry;
|
||||
}
|
||||
|
||||
/** Never carries the offending permission id, route path, module id, or any other configuration
|
||||
@@ -195,9 +201,10 @@ function sanitizeModuleResponse(response: Response): Response {
|
||||
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
|
||||
* response body afterward — there is no separate `HEAD` registration or permission path.
|
||||
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
|
||||
* frozen context carrying exactly `moduleId`, `principal`, `params`, and `services` — never a Hono
|
||||
* `Context`, authorization store, or configuration; `services.database` is the module's only
|
||||
* reachable database surface, and is unreachable to anything short of a successfully authorized
|
||||
* frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`,
|
||||
* `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or
|
||||
* deployment-wide configuration. `services.database` is the module's only reachable database
|
||||
* surface, and the whole context is unreachable to anything short of a successfully authorized
|
||||
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
|
||||
* this handler at all).
|
||||
*
|
||||
@@ -228,9 +235,12 @@ export function mountModuleHttpRoutes(
|
||||
|
||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||
// all) is enforced before permission evaluation ever runs, so an oversized request never
|
||||
// reaches authorization work, let alone the handler or the module database.
|
||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
|
||||
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
|
||||
// never reaches authorization work, let alone the handler or the module database. In
|
||||
// `streamed` mode no body byte is read until the handler reads it, and the handler runs only
|
||||
// after authorization succeeds, so an unauthorized caller can never push an upload through
|
||||
// (ADR 0042).
|
||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||
const path = moduleRoutePath(route.moduleId, route.path);
|
||||
const finalHandler = async (c: Context): Promise<Response> => {
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
@@ -239,6 +249,7 @@ export function mountModuleHttpRoutes(
|
||||
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
realtime: options.realtime.publisherFor(route.moduleId),
|
||||
services: options.services,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { ModuleRealtimeError, type ModuleId, type PermissionId } from "@ekklesia/contracts";
|
||||
import type {
|
||||
ModuleRealtimePublication,
|
||||
ModuleRealtimeRegistry,
|
||||
RegisteredModuleRealtimeChannel,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
|
||||
const MODULE_REALTIME_PREFIX = "/api/v1/module-realtime";
|
||||
|
||||
/** Host-owned keepalive interval. Comments keep idle HTTP streams alive without entering module code,
|
||||
* consuming a sequence, or becoming a browser-visible state update. */
|
||||
export const MODULE_REALTIME_KEEPALIVE_MS = 15_000;
|
||||
/** Forces a fresh cookie/permission evaluation periodically rather than letting an authorization
|
||||
* decision live for an unbounded stream lifetime. EventSource reconnects after the clean EOF. */
|
||||
export const MODULE_REALTIME_MAX_CONNECTION_AGE_MS = 5 * 60_000;
|
||||
|
||||
export interface ModuleRealtimeTransportTiming {
|
||||
/** Test-only narrowing of the production keepalive. Production composition never sets this. */
|
||||
readonly keepaliveMs?: number;
|
||||
/** Test-only narrowing of the production reauthorization interval. */
|
||||
readonly maxConnectionAgeMs?: number;
|
||||
}
|
||||
|
||||
export interface ModuleRealtimeRoutesOptions
|
||||
extends AuthorizationMiddlewareOptions,
|
||||
ModuleRealtimeTransportTiming {
|
||||
readonly realtime: ModuleRealtimeRegistry;
|
||||
}
|
||||
|
||||
/** The server is the sole author of this deployment-private base. Module UI receives it through the
|
||||
* host descriptor and never reconstructs the platform prefix or supplies its own module id. */
|
||||
export function moduleRealtimeBaseUrl(moduleId: string): string {
|
||||
return `${MODULE_REALTIME_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
function moduleRealtimeChannelUrl(moduleId: ModuleId, channel: string): string {
|
||||
return `${moduleRealtimeBaseUrl(moduleId)}/${encodeURIComponent(channel)}`;
|
||||
}
|
||||
|
||||
/** A forged runtime snapshot that could not be permission-gated is a composition failure. Carries no
|
||||
* module/channel/permission value and is thrown before any route from that snapshot is usable. */
|
||||
export class ModuleRealtimeConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("A published module realtime channel references a permission that is not registered.");
|
||||
this.name = "ModuleRealtimeConfigurationError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function realtimeEnvelope(publication: ModuleRealtimePublication): string {
|
||||
// `serializedPayload` was validated and serialized exactly once by Core. Concatenating it as the
|
||||
// JSON value preserves every opaque string — including signed object-delivery URLs — byte for byte.
|
||||
return `{"sequence":${publication.sequence},"payload":${publication.serializedPayload}}`;
|
||||
}
|
||||
|
||||
function realtimeEvent(
|
||||
kind: "snapshot" | "update",
|
||||
publication: ModuleRealtimePublication,
|
||||
): string {
|
||||
// Core's compact JSON serialization contains no literal CR/LF bytes inside strings, so one `data`
|
||||
// line is sufficient. Deliberately no SSE `id`: this transport does not implement Last-Event-ID
|
||||
// replay, and a process-local sequence must not be mistaken for a durable cursor.
|
||||
return `event: ${kind}\ndata: ${realtimeEnvelope(publication)}\n\n`;
|
||||
}
|
||||
|
||||
function sanitizedRealtimeFailure(c: Context, error: unknown): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const code =
|
||||
error instanceof ModuleRealtimeError && error.code === "REALTIME_SUBSCRIBER_LIMIT"
|
||||
? "REALTIME_SUBSCRIBER_LIMIT"
|
||||
: "REALTIME_UNAVAILABLE";
|
||||
return c.json({ code }, 503);
|
||||
}
|
||||
|
||||
interface CancelableDelay {
|
||||
readonly promise: Promise<"keepalive">;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
function keepaliveDelay(milliseconds: number): CancelableDelay {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const promise = new Promise<"keepalive">((resolve) => {
|
||||
timer = setTimeout(() => resolve("keepalive"), milliseconds);
|
||||
});
|
||||
return {
|
||||
cancel: () => clearTimeout(timer),
|
||||
promise,
|
||||
};
|
||||
}
|
||||
|
||||
function createRealtimeStream(
|
||||
definition: RegisteredModuleRealtimeChannel,
|
||||
registry: ModuleRealtimeRegistry,
|
||||
keepaliveMs: number,
|
||||
maxConnectionAgeMs: number,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const subscription = registry.subscribe(definition.moduleId, definition.channel);
|
||||
let snapshotPending = true;
|
||||
let closed = false;
|
||||
let pendingRead: Promise<ModuleRealtimePublication | undefined> | undefined;
|
||||
let activeDelay: CancelableDelay | undefined;
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const close = (): void => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
activeDelay?.cancel();
|
||||
activeDelay = undefined;
|
||||
clearTimeout(expiryTimer);
|
||||
expiryTimer = undefined;
|
||||
subscription.close();
|
||||
};
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
expiryTimer = setTimeout(() => {
|
||||
close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// A simultaneous browser cancellation or completed pull already closed the controller.
|
||||
}
|
||||
}, maxConnectionAgeMs);
|
||||
},
|
||||
cancel: () => close(),
|
||||
async pull(controller) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (snapshotPending) {
|
||||
snapshotPending = false;
|
||||
controller.enqueue(encoder.encode(realtimeEvent("snapshot", subscription.snapshot)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only one Core read is ever outstanding. If a keepalive wins the race, the same read is
|
||||
// retained for the next pull instead of allocating a second listener. Core itself holds only
|
||||
// one newest pending publication, and this stream asks for work only when the browser-facing
|
||||
// ReadableStream has demand, so a slow client cannot create an array at either layer.
|
||||
pendingRead ??= subscription.read();
|
||||
activeDelay = keepaliveDelay(keepaliveMs);
|
||||
const result = await Promise.race([pendingRead, activeDelay.promise]);
|
||||
activeDelay.cancel();
|
||||
activeDelay = undefined;
|
||||
|
||||
if (result === "keepalive") {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRead = undefined;
|
||||
if (result === undefined) {
|
||||
close();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(realtimeEvent("update", result)));
|
||||
} catch {
|
||||
close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// The browser may have canceled between the awaited read/timer and this close. Cancellation
|
||||
// already performed the same idempotent subscription cleanup.
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mountChannel(
|
||||
app: Hono,
|
||||
definition: RegisteredModuleRealtimeChannel,
|
||||
options: ModuleRealtimeRoutesOptions,
|
||||
keepaliveMs: number,
|
||||
maxConnectionAgeMs: number,
|
||||
): void {
|
||||
const requireChannelPermission = requirePermission(
|
||||
options,
|
||||
definition.permission as PermissionId,
|
||||
);
|
||||
app.get(
|
||||
moduleRealtimeChannelUrl(definition.moduleId, definition.channel),
|
||||
requireChannelPermission,
|
||||
(c) => {
|
||||
// Hono deliberately lets a registered GET handler answer HEAD. That is useful for finite
|
||||
// resources, but unsafe for an infinite stream: the adapter discards the response body without
|
||||
// necessarily canceling it, which could retain a Core subscriber forever. Refuse HEAD before
|
||||
// allocating anything. Authentication/authorization has still run through the same middleware.
|
||||
if (c.req.method !== "GET") {
|
||||
return new Response(null, {
|
||||
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
||||
status: 405,
|
||||
});
|
||||
}
|
||||
let stream: ReadableStream<Uint8Array>;
|
||||
try {
|
||||
stream = createRealtimeStream(
|
||||
definition,
|
||||
options.realtime,
|
||||
keepaliveMs,
|
||||
maxConnectionAgeMs,
|
||||
);
|
||||
} catch (error) {
|
||||
return sanitizedRealtimeFailure(c, error);
|
||||
}
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
Connection: "keep-alive",
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
Vary: "Origin, Cookie",
|
||||
// Honored by nginx and harmless elsewhere. Other proxies must likewise disable buffering for
|
||||
// this path; no proxy-specific behavior enters the module-facing contract.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Mounts one exact, immutable, permission-gated GET route per successfully started module channel.
|
||||
* There is no dynamic module/channel lookup route and no request-controlled permission selection. */
|
||||
export function mountModuleRealtimeRoutes(app: Hono, options: ModuleRealtimeRoutesOptions): void {
|
||||
const keepaliveMs = options.keepaliveMs ?? MODULE_REALTIME_KEEPALIVE_MS;
|
||||
const maxConnectionAgeMs = options.maxConnectionAgeMs ?? MODULE_REALTIME_MAX_CONNECTION_AGE_MS;
|
||||
if (!Number.isSafeInteger(keepaliveMs) || keepaliveMs <= 0) {
|
||||
throw new TypeError("The module realtime keepalive interval is invalid.");
|
||||
}
|
||||
if (!Number.isSafeInteger(maxConnectionAgeMs) || maxConnectionAgeMs <= 0) {
|
||||
throw new TypeError("The module realtime maximum connection age is invalid.");
|
||||
}
|
||||
for (const definition of options.realtime.channels) {
|
||||
if (!options.registry.has(definition.permission)) {
|
||||
throw new ModuleRealtimeConfigurationError();
|
||||
}
|
||||
}
|
||||
for (const definition of options.realtime.channels) {
|
||||
mountChannel(app, definition, options, keepaliveMs, maxConnectionAgeMs);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules";
|
||||
import type {
|
||||
ModuleUiPublicationRegistry,
|
||||
PublishableModuleContribution,
|
||||
PublishableModuleUi,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
@@ -9,7 +13,13 @@ import {
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js";
|
||||
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-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
|
||||
@@ -39,11 +49,18 @@ export interface HostModuleUiDescriptor {
|
||||
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
||||
* routing can change without touching a single line of host or module code. */
|
||||
readonly ui: { readonly url: string };
|
||||
/** Canonical page for this module's separately declared display artifact, or `null` when the
|
||||
* module publishes no display. Server-produced so neither shell nor module spells platform
|
||||
* routing or infers deployment topology. */
|
||||
readonly display: { readonly url: string } | null;
|
||||
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
|
||||
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
|
||||
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
|
||||
* module nor the browser host should be the one spelling it. */
|
||||
readonly api: { readonly baseUrl: string };
|
||||
/** Server-produced root for this module's host-neutral browser realtime facade. The browser host,
|
||||
* not module UI, resolves and appends a validated local channel. */
|
||||
readonly realtime: { readonly baseUrl: string };
|
||||
/**
|
||||
* The subset of **this module's own declared permissions** that the current principal holds,
|
||||
* evaluated server-side by Core's one authorization evaluator.
|
||||
@@ -60,12 +77,56 @@ export interface HostModuleUiDescriptor {
|
||||
* nor trusts anything in this array.
|
||||
*/
|
||||
readonly permissions: readonly string[];
|
||||
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
|
||||
* the overwhelming majority of modules, which provide no extension point. */
|
||||
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
|
||||
*
|
||||
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
|
||||
* consuming module names a contribution and the host resolves it, so no module ever builds
|
||||
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
|
||||
*
|
||||
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
|
||||
* contributed renderer draws and what shape its input takes are the consuming module's contract,
|
||||
* carried in the browser between two modules without passing through the platform.
|
||||
*/
|
||||
export interface HostModuleContributionDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
/** The capability it was contributed to — one this module declares. */
|
||||
readonly capability: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface HostModuleUiInventory {
|
||||
readonly modules: readonly HostModuleUiDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
|
||||
* inventory and the display descriptor so the two can never disagree about what a consuming module
|
||||
* may mount.
|
||||
*/
|
||||
export function describeModuleContributions(
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
moduleId: string,
|
||||
): readonly HostModuleContributionDescriptor[] {
|
||||
return registry
|
||||
.contributionsForModule(moduleId)
|
||||
.map((contribution: PublishableModuleContribution) => ({
|
||||
capability: contribution.capability,
|
||||
id: contribution.contributionId,
|
||||
url: moduleContributionArtifactUrl(
|
||||
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
|
||||
contribution.contributionId,
|
||||
),
|
||||
version: contribution.version,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||
@@ -86,7 +147,9 @@ export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddl
|
||||
* silently read "denied" during a database outage would blank the whole UI and look like a
|
||||
* permissions problem.
|
||||
*/
|
||||
async function grantedPermissionIds(
|
||||
/** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same
|
||||
* presentation-only subset of a module's own declarations and use Core's one evaluator. */
|
||||
export async function grantedModulePermissionIds(
|
||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||
principal: EkklesiaPrincipal,
|
||||
declarations: readonly PermissionDeclaration[],
|
||||
@@ -110,11 +173,16 @@ async function describeModule(
|
||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||
principal: EkklesiaPrincipal,
|
||||
published: PublishableModuleUi,
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
): Promise<HostModuleUiDescriptor> {
|
||||
const display = registry.resolveDisplay(published.moduleId, published.version);
|
||||
return {
|
||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||
contributions: describeModuleContributions(registry, published.moduleId),
|
||||
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
|
||||
id: published.moduleId,
|
||||
permissions: await grantedPermissionIds(evaluator, principal, published.permissions),
|
||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||
ui: { url: moduleUiArtifactUrl(published) },
|
||||
version: published.version,
|
||||
};
|
||||
@@ -146,7 +214,7 @@ export async function describeMountableModules(
|
||||
): Promise<readonly MountableModuleDescription[]> {
|
||||
return Promise.all(
|
||||
registry.list().map(async (published) => ({
|
||||
descriptor: await describeModule(evaluator, principal, published),
|
||||
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||
name: published.name,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -77,9 +77,42 @@ export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical browser URL for a module's independently declared display artifact.
|
||||
*
|
||||
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
|
||||
* separate helper and an exact route means neither request input nor a generic role parameter can
|
||||
* select a neighbouring artifact. A future standalone host may expose a different outer display
|
||||
* page while consuming this same published module artifact internally.
|
||||
*/
|
||||
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
|
||||
*
|
||||
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
|
||||
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
|
||||
* module id and version are: a value that somehow did not come from a validated manifest can only
|
||||
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
|
||||
* fragment — and a value that needed encoding simply fails to resolve against the registry.
|
||||
*
|
||||
* Like every other publication helper, this takes **identity only**. There is no parameter for a
|
||||
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
|
||||
* module package.
|
||||
*/
|
||||
export function moduleContributionArtifactUrl(
|
||||
identity: ModuleUiIdentity,
|
||||
contributionId: string,
|
||||
): string {
|
||||
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||
* installed module declares no `entrypoints.ui`, or the requested version is not the installed one.
|
||||
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||
* the installed one.
|
||||
*
|
||||
* They are deliberately indistinguishable. The route requires no authentication (see
|
||||
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
|
||||
@@ -122,6 +155,48 @@ function isNotModified(headerValue: string | undefined, etag: string): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
interface PublishedArtifact {
|
||||
readonly artifactPath: string;
|
||||
}
|
||||
|
||||
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
|
||||
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
|
||||
// Discovery validated this exact path earlier; the file may still have changed or vanished
|
||||
// since. The response is produced from whatever a single read of that one path returns right
|
||||
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
|
||||
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
|
||||
// something else.
|
||||
let artifact: Buffer;
|
||||
try {
|
||||
artifact = await readFile(published.artifactPath);
|
||||
} catch {
|
||||
return artifactUnavailable(c);
|
||||
}
|
||||
|
||||
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
|
||||
// validator can never describe a different revision than the body does.
|
||||
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
|
||||
if (isNotModified(c.req.header("if-none-match"), etag)) {
|
||||
return new Response(null, {
|
||||
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(artifact, {
|
||||
headers: {
|
||||
"Cache-Control": MODULE_UI_CACHE_CONTROL,
|
||||
"Content-Type": MODULE_UI_CONTENT_TYPE,
|
||||
ETag: etag,
|
||||
// The declared type is the only type: a browser must not be allowed to sniff a module
|
||||
// artifact into some other interpretation, and `import()` requires the JavaScript type
|
||||
// anyway.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateModuleUiRoutesOptions {
|
||||
/** The platform's publication policy, built by Core from the discovered, started module
|
||||
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
|
||||
@@ -136,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
|
||||
*
|
||||
* ## The request never touches the filesystem
|
||||
*
|
||||
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
|
||||
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
|
||||
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||
* `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
||||
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
||||
* are all unreachable by construction rather than by filtering. An installed module directory never
|
||||
@@ -173,46 +248,41 @@ export interface CreateModuleUiRoutesOptions {
|
||||
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(
|
||||
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
|
||||
async (c) => {
|
||||
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
|
||||
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
|
||||
// UI artifact and on no others.
|
||||
const published = options.registry.resolveContribution(
|
||||
c.req.param("moduleId"),
|
||||
c.req.param("version"),
|
||||
c.req.param("contributionId"),
|
||||
);
|
||||
if (!published) {
|
||||
return notPublished(c);
|
||||
}
|
||||
return serveArtifact(c, published);
|
||||
},
|
||||
);
|
||||
|
||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||
const published = options.registry.resolveDisplay(
|
||||
c.req.param("moduleId"),
|
||||
c.req.param("version"),
|
||||
);
|
||||
if (!published) {
|
||||
return notPublished(c);
|
||||
}
|
||||
return serveArtifact(c, published);
|
||||
});
|
||||
|
||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
|
||||
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
|
||||
if (!published) {
|
||||
return notPublished(c);
|
||||
}
|
||||
|
||||
// Discovery validated this exact path earlier; the file may still have changed or vanished
|
||||
// since. The response is produced from whatever a single read of that one path returns right
|
||||
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
|
||||
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
|
||||
// something else.
|
||||
let artifact: Buffer;
|
||||
try {
|
||||
artifact = await readFile(published.artifactPath);
|
||||
} catch {
|
||||
return artifactUnavailable(c);
|
||||
}
|
||||
|
||||
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
|
||||
// validator can never describe a different revision than the body does.
|
||||
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
|
||||
if (isNotModified(c.req.header("if-none-match"), etag)) {
|
||||
return new Response(null, {
|
||||
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(artifact, {
|
||||
headers: {
|
||||
"Cache-Control": MODULE_UI_CACHE_CONTROL,
|
||||
"Content-Type": MODULE_UI_CONTENT_TYPE,
|
||||
ETag: etag,
|
||||
// The declared type is the only type: a browser must not be allowed to sniff a module
|
||||
// artifact into some other interpretation, and `import()` requires the JavaScript type
|
||||
// anyway.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
return serveArtifact(c, published);
|
||||
});
|
||||
|
||||
return routes;
|
||||
|
||||
+148
-86
@@ -11,9 +11,16 @@ import {
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||
import {
|
||||
createIdentityNotifications,
|
||||
createIdentityRuntime,
|
||||
type IdentityNotificationReporter,
|
||||
type IdentityRateLimitOverride,
|
||||
} from "@ekklesia/core/identity";
|
||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||
import {
|
||||
createEnvironmentModuleSecretSource,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
@@ -33,13 +40,16 @@ import { type ServerType, serve } from "@hono/node-server";
|
||||
import type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import {
|
||||
type ClientAddressConfigInput,
|
||||
type ClientAddressResolver,
|
||||
createClientAddressResolver,
|
||||
wrapFetchWithClientAddress,
|
||||
} from "./client-address.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
|
||||
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||
import { withDevicePrincipals } from "./device-principal.js";
|
||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
|
||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||
|
||||
const connectionTimeoutMs = 10_000;
|
||||
@@ -135,6 +145,15 @@ export interface StartApiOptions {
|
||||
/** Test-only escape hatch: never set by the executable entry point. See
|
||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||
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
|
||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
@@ -176,6 +195,18 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the mail provider's transport. */
|
||||
async function safeCloseMail(provider: MailProvider | undefined): Promise<void> {
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await provider.close();
|
||||
} catch {
|
||||
// See safeCloseDatabase: never mask a failure that is already propagating.
|
||||
}
|
||||
}
|
||||
|
||||
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own
|
||||
* connection pool. */
|
||||
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
|
||||
@@ -415,23 +446,47 @@ export async function startApi(
|
||||
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 runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||
let deviceStore: ReturnType<typeof createDeviceStore>;
|
||||
try {
|
||||
services = Object.freeze({ database: databaseHandle.module });
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.identity,
|
||||
db: databaseHandle.identity,
|
||||
// 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 } : {}),
|
||||
});
|
||||
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
||||
deviceStore = createDeviceStore(databaseHandle.devices);
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
// Device authority reaches the one evaluator here. There is no second decision point: a device
|
||||
// is allowed or denied by the same function, with the same deny-by-default, as a person.
|
||||
devices: deviceStore,
|
||||
grants: authorizationStore,
|
||||
platformOwners: authorizationStore,
|
||||
});
|
||||
@@ -440,13 +495,6 @@ export async function startApi(
|
||||
// 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
|
||||
// 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;
|
||||
|
||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||
databaseHandle.moduleConfiguration,
|
||||
@@ -474,6 +522,7 @@ export async function startApi(
|
||||
);
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
@@ -489,6 +538,28 @@ export async function startApi(
|
||||
})),
|
||||
);
|
||||
|
||||
// This snapshot is inert metadata only: construction reads no artifact and executes no module.
|
||||
// It lets the API prove that a display host build is available *before* importing any eligible
|
||||
// module code. The snapshot is not exposed anywhere unless the complete runtime subsequently
|
||||
// starts and application composition succeeds.
|
||||
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
|
||||
try {
|
||||
displayHostAssets =
|
||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||
// so a missing shell build fails here, before any module code is imported.
|
||||
// The page learns its API origin from the API that serves it, so one build serves either
|
||||
// topology (ADR 0045).
|
||||
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
||||
// handle to stop here, only the pool this failure leaves open.
|
||||
@@ -508,10 +579,15 @@ export async function startApi(
|
||||
});
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
|
||||
// `503` while the instance still finishes in-flight requests.
|
||||
let shuttingDown = false;
|
||||
|
||||
let app: Hono;
|
||||
try {
|
||||
app = createApiApp({
|
||||
@@ -521,13 +597,15 @@ export async function startApi(
|
||||
store: authorizationStore,
|
||||
},
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleRealtime: moduleRuntime.realtime,
|
||||
moduleServices: services,
|
||||
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||
// inventory, and only once startup has succeeded — so the published set is the running set. A
|
||||
// disabled or dependency-blocked module therefore publishes no artifact, appears in no browser
|
||||
// inventory, and appears in no resolved Dashboard navigation, by construction rather than by a
|
||||
// filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent.
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
// inventory, and exposed only after startup succeeded — so the published set is the running
|
||||
// set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no
|
||||
// browser inventory, and appears in no resolved Dashboard navigation, by construction rather
|
||||
// than by a filter somewhere downstream. Modules declaring no UI role are simply absent.
|
||||
moduleUi,
|
||||
...(displayHostAssets === undefined ? {} : { displayHostAssets }),
|
||||
moduleLifecycle: {
|
||||
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
|
||||
// reports it actually started — read from the runtime rather than assumed equal to the
|
||||
@@ -554,12 +632,31 @@ export async function startApi(
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
runtime,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
dashboardHostAssets,
|
||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||
health: {
|
||||
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
|
||||
isModuleStartupComplete: () => !shuttingDown,
|
||||
},
|
||||
devices: {
|
||||
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
||||
// base URL rather than from any request. Production is https by configuration policy, so a
|
||||
// real deployment always gets a Secure device cookie; a plaintext development origin would
|
||||
// silently drop one.
|
||||
secureCookies: config.identity.baseUrl.startsWith("https://"),
|
||||
store: deviceStore,
|
||||
},
|
||||
// Principal resolution, extended with managed devices. A human session still wins, and this
|
||||
// is the only place a device credential ever becomes a principal.
|
||||
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
|
||||
// The configured origins plus the API's own (ADR 0045).
|
||||
trustedOrigins: apiTrustedOrigins(config.identity),
|
||||
});
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
@@ -576,6 +673,7 @@ export async function startApi(
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
@@ -585,7 +683,9 @@ export async function startApi(
|
||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
||||
// actually drained or the pool had actually closed. Storing the promise itself means every
|
||||
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
||||
// observes it complete (or fail) exactly once. Order matters, and is now additionally
|
||||
// observes it complete (or fail) exactly once. Realtime subscriptions are closed *before* HTTP
|
||||
// drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every
|
||||
// clean shutdown open until the forced-drain deadline. Order matters, and is now additionally
|
||||
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
|
||||
// no new or in-flight request can reach a module handler while the module runtime is stopping;
|
||||
// then the module runtime stops — its stop hooks may still use `services.database`, which is
|
||||
@@ -597,7 +697,12 @@ export async function startApi(
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
shuttingDown = true;
|
||||
shutdownPromise = (async () => {
|
||||
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
||||
// completes: an in-flight module command may still commit and publish its resulting state.
|
||||
// `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run.
|
||||
moduleRuntime.realtime.closeSubscriptions();
|
||||
const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
|
||||
let moduleStopError: unknown;
|
||||
try {
|
||||
@@ -608,6 +713,7 @@ export async function startApi(
|
||||
// After the module runtime has stopped, so a `stop` hook may still use its storage
|
||||
// facade, and before the pool closes, matching the existing ordering discipline exactly.
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
|
||||
// module-stop failure can never leave the pool open. Both failures are reported together,
|
||||
@@ -651,84 +757,40 @@ export function installShutdownSignalHandlers(
|
||||
process.on("SIGTERM", handleSignal);
|
||||
}
|
||||
|
||||
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined {
|
||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
||||
if (mode === undefined) {
|
||||
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/
|
||||
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally
|
||||
// half-configuring proxy mode by setting only one of the three variables.
|
||||
return undefined;
|
||||
}
|
||||
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
|
||||
return {
|
||||
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
||||
mode,
|
||||
trustedProxies: trustedProxiesValue
|
||||
?.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function readConfigFromEnvironment(): {
|
||||
/**
|
||||
* Turns one deployment environment into this process's startup inputs.
|
||||
*
|
||||
* Every host infrastructure value comes from `loadHostConfiguration` — the same function
|
||||
* `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
|
||||
* it can never disagree about what this deployment is configured to use. Only the two values that
|
||||
* describe *this listener* rather than the host's infrastructure are read here.
|
||||
*/
|
||||
export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
|
||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||
readonly environment: "development" | "production";
|
||||
readonly moduleSecrets: ModuleSecretSource;
|
||||
} {
|
||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||
const trustedOrigins = trustedOriginsValue
|
||||
?.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
const clientAddress = readClientAddressInputFromEnvironment();
|
||||
|
||||
const host = loadHostConfiguration(processEnvironment);
|
||||
return {
|
||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||
environment: host.environment,
|
||||
input: {
|
||||
...(clientAddress === undefined ? {} : { clientAddress }),
|
||||
databaseTransport: {
|
||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
||||
? {}
|
||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||
? {}
|
||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
||||
},
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
host: process.env.EKKLESIA_API_HOST,
|
||||
identity: {
|
||||
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
||||
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
||||
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
||||
trustedOrigins,
|
||||
},
|
||||
port: process.env.EKKLESIA_API_PORT,
|
||||
// The one place this process turns its environment into storage configuration. The names are
|
||||
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
|
||||
// validator never reads `process.env` itself.
|
||||
storage: {
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
bucketPrefix: process.env.S3_BUCKET_PREFIX,
|
||||
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
|
||||
// storage by is not a name a browser could resolve.
|
||||
deliveryEndpoint: process.env.S3_DELIVERY_ENDPOINT,
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE,
|
||||
region: process.env.S3_REGION,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
},
|
||||
clientAddress: host.clientAddress,
|
||||
databaseTransport: host.databaseTransport,
|
||||
databaseUrl: host.databaseUrl,
|
||||
host: processEnvironment.EKKLESIA_API_HOST,
|
||||
identity: host.identity,
|
||||
mail: host.mail,
|
||||
port: processEnvironment.EKKLESIA_API_PORT,
|
||||
storage: host.storage,
|
||||
},
|
||||
moduleSecrets: host.moduleSecrets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const { environment, input } = readConfigFromEnvironment();
|
||||
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
|
||||
const config = createApiRuntimeConfig(input, { environment });
|
||||
// The one place this process turns its environment into module secrets. Core's factory takes the
|
||||
// environment as an argument and never touches `process.env` itself, preserving the rule that
|
||||
// only executables read the environment.
|
||||
const running = await startApi(config, {
|
||||
moduleSecrets: createEnvironmentModuleSecretSource(process.env),
|
||||
});
|
||||
const running = await startApi(config, { moduleSecrets });
|
||||
|
||||
const address = running.server.address();
|
||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import net from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
|
||||
import {
|
||||
bootstrapPlatformOwner,
|
||||
@@ -15,18 +17,34 @@ import {
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "@ekklesia/core/migrations";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
bootstrapHost,
|
||||
type EkklesiaHostConfig,
|
||||
signInWithEmail,
|
||||
signOutHost,
|
||||
} from "@ekklesia/shell";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { createApiDatabase } from "../src/database.js";
|
||||
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import {
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import {
|
||||
MODULE_UI_CACHE_CONTROL,
|
||||
MODULE_UI_CONTENT_TYPE,
|
||||
moduleUiArtifactUrl,
|
||||
} from "../src/module-ui-routes.js";
|
||||
import { startApi, type NodeServerTimeouts, type RunningApi } from "../src/server.js";
|
||||
import { type NodeServerTimeouts, type RunningApi, startApi } from "../src/server.js";
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
@@ -52,6 +70,26 @@ function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
async function withTransportDeadline<Value>(
|
||||
operation: Promise<Value>,
|
||||
onTimeout: () => void,
|
||||
): Promise<Value> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
onTimeout();
|
||||
reject(harnessError("An integration transport operation timed out."));
|
||||
}, 5_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
@@ -197,6 +235,8 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -302,7 +342,7 @@ async function createGatedRouteModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||
version: "0.1.0",
|
||||
@@ -313,22 +353,25 @@ async function createGatedRouteModule(
|
||||
`export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
register({ http, realtime }) {
|
||||
realtime.channel({ channel: "state", permission: ${JSON.stringify(permissionId)} });
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/gated",
|
||||
permission: ${JSON.stringify(permissionId)},
|
||||
maxBodyBytes: 0,
|
||||
async handler() {
|
||||
async handler(_request, context) {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)});
|
||||
${gate.expression}.enter();
|
||||
await ${gate.expression}.released;
|
||||
context.realtime.publish("state", { status: "finished" });
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:handler-published`)});
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:handler-finished`)});
|
||||
return Response.json({ gated: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
start() {},
|
||||
start({ realtime }) { realtime.publish("state", { status: "waiting" }); },
|
||||
stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); },
|
||||
};`,
|
||||
),
|
||||
@@ -343,7 +386,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -355,6 +398,9 @@ async function createFixtureModule(
|
||||
const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some(
|
||||
(declaration) => declaration.id === "calendar.events.read",
|
||||
);
|
||||
const realtimePermission = (manifest.permissions as ReadonlyArray<{ id: string }>).find(
|
||||
(declaration) => declaration.id === `${id}.state.view`,
|
||||
)?.id;
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
@@ -372,8 +418,13 @@ async function createFixtureModule(
|
||||
`export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
register({ http, realtime }) {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
|
||||
${
|
||||
realtimePermission
|
||||
? `realtime.channel({ channel: "state", permission: ${JSON.stringify(realtimePermission)} });`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
declaresCalendarRead
|
||||
? `http.route({
|
||||
@@ -393,11 +444,49 @@ async function createFixtureModule(
|
||||
async handler(request, context) {
|
||||
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
||||
},
|
||||
});
|
||||
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
|
||||
// arrive and never materializes the body, which is exactly what a real module
|
||||
// storing an upload would do.
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/uploads",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 33554432,
|
||||
bodyMode: "streamed",
|
||||
async handler(request) {
|
||||
let bytes = 0;
|
||||
if (request.body) {
|
||||
const reader = request.body.getReader();
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) { break; }
|
||||
bytes += chunk.value.byteLength;
|
||||
}
|
||||
}
|
||||
return Response.json({ bytes }, { status: 201 });
|
||||
},
|
||||
});
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/small-uploads",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 64,
|
||||
bodyMode: "streamed",
|
||||
async handler(request) {
|
||||
await request.arrayBuffer().catch(() => undefined);
|
||||
return Response.json({ swallowed: true }, { status: 201 });
|
||||
},
|
||||
});`
|
||||
: ""
|
||||
}
|
||||
},
|
||||
start() {
|
||||
start({ realtime }) {
|
||||
${
|
||||
realtimePermission
|
||||
? `realtime.publish("state", { module: ${JSON.stringify(id)}, status: "running" });`
|
||||
: ""
|
||||
}
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
||||
},
|
||||
stop() {
|
||||
@@ -435,7 +524,7 @@ async function createUiModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -479,6 +568,32 @@ function apiOrigin(running: RunningApi): string {
|
||||
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 {
|
||||
readonly clientAddress?: ClientAddressConfigInput;
|
||||
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
|
||||
@@ -494,6 +609,16 @@ interface StartConfiguredApiOptions {
|
||||
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(
|
||||
url: string,
|
||||
options: StartConfiguredApiOptions = {},
|
||||
@@ -528,7 +653,7 @@ async function startConfiguredApi(
|
||||
},
|
||||
{ environment },
|
||||
);
|
||||
return startApi(config, {
|
||||
const running = await startApi(config, {
|
||||
...("rateLimitOverride" in options
|
||||
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
||||
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
||||
@@ -542,6 +667,8 @@ async function startConfiguredApi(
|
||||
? {}
|
||||
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
|
||||
});
|
||||
databaseUrlByOrigin.set(apiOrigin(running), url);
|
||||
return running;
|
||||
}
|
||||
|
||||
async function startMigratedApi(
|
||||
@@ -566,6 +693,139 @@ function firstCookiePair(setCookieHeader: string): string {
|
||||
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
interface SafeCookieObservation {
|
||||
readonly domain: string | undefined;
|
||||
readonly httpOnly: boolean;
|
||||
readonly path: string | undefined;
|
||||
readonly sameSite: string | undefined;
|
||||
readonly secure: boolean;
|
||||
}
|
||||
|
||||
interface BrowserAuthResponseObservation {
|
||||
readonly accessControlAllowCredentials: string | null;
|
||||
readonly accessControlAllowOrigin: string | null;
|
||||
readonly body: string;
|
||||
readonly contentLength: string | null;
|
||||
readonly cookie: SafeCookieObservation;
|
||||
readonly issuedSetCookie: boolean;
|
||||
readonly status: number;
|
||||
}
|
||||
|
||||
interface OriginLockedBrowserCookieJar {
|
||||
readonly fetch: typeof fetch;
|
||||
hasSessionCookie(): boolean;
|
||||
signInResponse(): BrowserAuthResponseObservation;
|
||||
signOutResponse(): BrowserAuthResponseObservation;
|
||||
}
|
||||
|
||||
function observeCookie(setCookie: string | undefined): SafeCookieObservation {
|
||||
const attributes = (setCookie ?? "")
|
||||
.split(";")
|
||||
.slice(1)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const attributeValue = (name: string): string | undefined => {
|
||||
const prefix = `${name.toLowerCase()}=`;
|
||||
const attribute = attributes.find((part) => part.toLowerCase().startsWith(prefix));
|
||||
return attribute?.slice(prefix.length);
|
||||
};
|
||||
const hasAttribute = (name: string): boolean =>
|
||||
attributes.some((part) => part.toLowerCase() === name.toLowerCase());
|
||||
return Object.freeze({
|
||||
domain: attributeValue("domain"),
|
||||
httpOnly: hasAttribute("httponly"),
|
||||
path: attributeValue("path"),
|
||||
sameSite: attributeValue("samesite"),
|
||||
secure: hasAttribute("secure"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Node deliberately has no ambient browser cookie store. This origin-locked test transport models
|
||||
* the one host-only session cookie explicitly: it accepts Set-Cookie only from the configured API,
|
||||
* sends only the cookie pair back to that same origin, clears it on an expiry response, and never
|
||||
* exposes the stored value to assertions or error output. The observable cookie data contains only
|
||||
* flags needed to prove the browser contract.
|
||||
*/
|
||||
function createOriginLockedBrowserCookieJar(apiBaseUrl: string): OriginLockedBrowserCookieJar {
|
||||
const apiOrigin = new URL(apiBaseUrl).origin;
|
||||
let cookiePair: string | undefined;
|
||||
let signInObservation: BrowserAuthResponseObservation | undefined;
|
||||
let signOutObservation: BrowserAuthResponseObservation | undefined;
|
||||
|
||||
const browserFetch: typeof fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
const requestUrl = new URL(request.url);
|
||||
if (requestUrl.origin !== apiOrigin) {
|
||||
throw harnessError("The browser integration transport refused a cross-origin request.");
|
||||
}
|
||||
|
||||
// A real cross-origin browser supplies Origin itself and its cookie jar only when
|
||||
// `credentials: include` is selected. Node's fetch has neither ambient behavior, so this
|
||||
// tightly scoped harness supplies exactly those two transport facts.
|
||||
request.headers.set("origin", dashboardOrigin);
|
||||
request.headers.delete("cookie");
|
||||
if (cookiePair !== undefined && request.credentials === "include") {
|
||||
request.headers.set("cookie", cookiePair);
|
||||
}
|
||||
|
||||
const response = await fetch(request);
|
||||
const setCookie = setCookieValue(response);
|
||||
if (setCookie !== undefined && request.credentials === "include") {
|
||||
const nextCookiePair = firstCookiePair(setCookie);
|
||||
const separator = nextCookiePair.indexOf("=");
|
||||
const nextName = separator < 0 ? "" : nextCookiePair.slice(0, separator);
|
||||
const nextValue = separator < 0 ? "" : nextCookiePair.slice(separator + 1);
|
||||
const expiresCookie = /(?:^|;)\s*max-age=0(?:;|$)/iu.test(setCookie);
|
||||
if (nextName !== "" && nextValue !== "" && !expiresCookie) {
|
||||
cookiePair = nextCookiePair;
|
||||
} else if (cookiePair?.startsWith(`${nextName}=`)) {
|
||||
cookiePair = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const pathname = requestUrl.pathname;
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
(pathname === "/api/auth/sign-in/email" || pathname === "/api/auth/sign-out")
|
||||
) {
|
||||
const observation = Object.freeze({
|
||||
accessControlAllowCredentials: response.headers.get("access-control-allow-credentials"),
|
||||
accessControlAllowOrigin: response.headers.get("access-control-allow-origin"),
|
||||
body: await response.clone().text(),
|
||||
contentLength: response.headers.get("content-length"),
|
||||
cookie: observeCookie(setCookie),
|
||||
issuedSetCookie: setCookie !== undefined,
|
||||
status: response.status,
|
||||
});
|
||||
if (pathname === "/api/auth/sign-in/email") {
|
||||
signInObservation = observation;
|
||||
} else {
|
||||
signOutObservation = observation;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
fetch: browserFetch,
|
||||
hasSessionCookie: () => cookiePair !== undefined,
|
||||
signInResponse: () => {
|
||||
if (signInObservation === undefined) {
|
||||
throw harnessError("The browser integration did not observe a sign-in response.");
|
||||
}
|
||||
return signInObservation;
|
||||
},
|
||||
signOutResponse: () => {
|
||||
if (signOutObservation === undefined) {
|
||||
throw harnessError("The browser integration did not observe a sign-out response.");
|
||||
}
|
||||
return signOutObservation;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface SignUpResult {
|
||||
readonly response: Response;
|
||||
readonly body: { token: string | null; user: { id: string; email: string } };
|
||||
@@ -587,6 +847,12 @@ async function signUp(
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -803,12 +1069,20 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
||||
const { cookiePair, response } = await signIn(origin, emailOne, password);
|
||||
const setCookie = setCookieValue(response);
|
||||
|
||||
const browserVisibleBody = await response.clone().text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(browserVisibleBody)).toEqual({ ok: true });
|
||||
expect(browserVisibleBody).not.toContain("token");
|
||||
expect(browserVisibleBody).not.toContain("user");
|
||||
expect(browserVisibleBody).not.toContain("session");
|
||||
expect(browserVisibleBody).not.toContain(emailOne);
|
||||
expect(setCookie).toBeDefined();
|
||||
expect(cookiePair).toBeDefined();
|
||||
sessionCookie = cookiePair as string;
|
||||
|
||||
const cookieText = setCookie as string;
|
||||
expect(cookieText).toContain("Path=/");
|
||||
expect(cookieText).toContain("HttpOnly");
|
||||
expect(cookieText).toContain("SameSite=Lax");
|
||||
expect(cookieText).toContain("Secure");
|
||||
@@ -1082,6 +1356,148 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — exported browser auth shell against PostgreSQL", () => {
|
||||
it("signs a real platform owner in, bootstraps protected Dashboard navigation, and signs out", async () => {
|
||||
const modulesDirectory = await createFixtureModulesDirectory();
|
||||
await createUiModule(
|
||||
modulesDirectory,
|
||||
"browser-shell",
|
||||
"1.0.0",
|
||||
'export default { id: "browser-shell", version: "1.0.0", mount() {} };\n',
|
||||
);
|
||||
const database = await createDisposableDatabase("browser_auth");
|
||||
await applyCoreMigrations(database.url);
|
||||
const running = await startMigratedApi(database.url, modulesDirectory);
|
||||
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
const email = `browser-owner-${randomBytes(6).toString("hex")}@example.test`;
|
||||
const password = "correct horse battery staple";
|
||||
const created = await signUp(origin, email, password);
|
||||
expect(created.response.status).toBe(200);
|
||||
|
||||
const authorizationDatabase = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
await bootstrapPlatformOwner(authorizationDatabase.db, {
|
||||
userId: parseEkklesiaUserId(created.body.user.id),
|
||||
});
|
||||
} finally {
|
||||
await authorizationDatabase.close();
|
||||
}
|
||||
|
||||
const cookieJar = createOriginLockedBrowserCookieJar(origin);
|
||||
const hostConfig: EkklesiaHostConfig = Object.freeze({
|
||||
apiBaseUrl: origin,
|
||||
fetch: cookieJar.fetch,
|
||||
});
|
||||
|
||||
// This is the public framework-free helper the browser shell itself calls — no test-side
|
||||
// reimplementation of its URL, payload, credentials mode, or response handling.
|
||||
await signInWithEmail(hostConfig, { email, password });
|
||||
|
||||
const signIn = cookieJar.signInResponse();
|
||||
expect(signIn.status).toBe(200);
|
||||
expect(JSON.parse(signIn.body)).toEqual({ ok: true });
|
||||
expect(
|
||||
signIn.contentLength === null ||
|
||||
signIn.contentLength === String(Buffer.byteLength(signIn.body)),
|
||||
).toBe(true);
|
||||
for (const forbidden of ["token", "user", "session", email]) {
|
||||
expect(signIn.body).not.toContain(forbidden);
|
||||
}
|
||||
expect(signIn.issuedSetCookie).toBe(true);
|
||||
expect(cookieJar.hasSessionCookie()).toBe(true);
|
||||
expect(signIn.cookie).toEqual({
|
||||
domain: undefined,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "Lax",
|
||||
secure: true,
|
||||
});
|
||||
expect(signIn.accessControlAllowOrigin).toBe(dashboardOrigin);
|
||||
expect(signIn.accessControlAllowCredentials).toBe("true");
|
||||
|
||||
// The pinned Better Auth build would return the raw session token from the first three
|
||||
// resources, and from change-password when `revokeOtherSessions` is true. Ekklesia has no
|
||||
// native session/password-management UI, so the outer API boundary must answer before Better
|
||||
// Auth can read these authenticated requests. The valid change-password payload makes this a
|
||||
// meaningful leak regression: if the denylist disappeared it could mint and expose a new
|
||||
// token, not merely fail validation before reaching the vulnerable response.
|
||||
const deniedNativeEndpoints = [
|
||||
{ method: "GET", path: "/api/auth/get-session?source=browser-shell" },
|
||||
{ method: "GET", path: "/api/auth/%67et-session" },
|
||||
{ method: "GET", path: "/api/auth/get-session%2F" },
|
||||
{ body: {}, method: "POST", path: "/api/auth/get-session" },
|
||||
{ method: "GET", path: "/api/auth/list-sessions" },
|
||||
{ body: { arbitrary: "value" }, method: "POST", path: "/api/auth/update-session" },
|
||||
{
|
||||
body: {
|
||||
currentPassword: password,
|
||||
newPassword: "replacement horse battery staple",
|
||||
revokeOtherSessions: true,
|
||||
},
|
||||
method: "POST",
|
||||
path: "/api/auth/change-password",
|
||||
},
|
||||
] as const;
|
||||
for (const endpoint of deniedNativeEndpoints) {
|
||||
const response = await cookieJar.fetch(`${origin}${endpoint.path}`, {
|
||||
credentials: "include",
|
||||
method: endpoint.method,
|
||||
...("body" in endpoint
|
||||
? {
|
||||
body: JSON.stringify(endpoint.body),
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const visibleBody = await response.text();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(JSON.parse(visibleBody)).toEqual({ code: "NOT_FOUND" });
|
||||
for (const forbidden of ["token", "session", email, password]) {
|
||||
expect(visibleBody).not.toContain(forbidden);
|
||||
}
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
}
|
||||
|
||||
// `bootstrapHost` resolves the principal first, then calls the protected Dashboard endpoint.
|
||||
// A real module in the result proves the latter request was authenticated and authorized.
|
||||
const authenticated = await bootstrapHost(hostConfig);
|
||||
expect(authenticated.principal).toEqual({
|
||||
kind: "authenticated",
|
||||
userId: created.body.user.id,
|
||||
});
|
||||
expect(authenticated.navigation.groups).toEqual([]);
|
||||
expect(authenticated.navigation.ungrouped.map((entry) => entry.descriptor.id)).toEqual([
|
||||
"browser-shell",
|
||||
]);
|
||||
|
||||
await signOutHost(hostConfig);
|
||||
|
||||
const signOut = cookieJar.signOutResponse();
|
||||
expect(signOut.status).toBe(200);
|
||||
expect(JSON.parse(signOut.body)).toEqual({ success: true });
|
||||
expect(signOut.issuedSetCookie).toBe(true);
|
||||
expect(signOut.accessControlAllowOrigin).toBe(dashboardOrigin);
|
||||
expect(signOut.accessControlAllowCredentials).toBe("true");
|
||||
expect(cookieJar.hasSessionCookie()).toBe(false);
|
||||
|
||||
// Reload through the same exported principal-first shell bootstrap. The expired cookie is no
|
||||
// longer sent, protected navigation is not probed, and the host returns its frozen empty view.
|
||||
const reloaded = await bootstrapHost(hostConfig);
|
||||
expect(reloaded.principal).toEqual({ kind: "anonymous" });
|
||||
expect(reloaded.navigation).toEqual({ groups: [], ungrouped: [] });
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — startup and migration guarantees", () => {
|
||||
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
||||
const database = await createDisposableDatabase("unmigrated");
|
||||
@@ -2192,10 +2608,10 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||
expect(permissionsCheck).toMatchObject({
|
||||
// Core's own five (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||
// `core.modules.configuration.manage`) plus the fixture module's one.
|
||||
details: ["6 permissions registered"],
|
||||
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
|
||||
details: ["7 permissions registered"],
|
||||
status: "pass",
|
||||
});
|
||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||
@@ -2314,6 +2730,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
||||
});
|
||||
|
||||
it("streams one image-sized upload end to end over real HTTP", async () => {
|
||||
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
|
||||
// travels through a real socket, a real Node server, and the platform's byte counter, and the
|
||||
// platform holds none of it (ADR 0042).
|
||||
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
|
||||
body: image,
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.json()).toEqual({ bytes: image.byteLength });
|
||||
}, 60_000);
|
||||
|
||||
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||
body: "a".repeat(65),
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
|
||||
// No declared `Content-Length`, so only the running byte count can bound this — and the
|
||||
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
|
||||
// wins: a limit a handler can swallow is not a limit.
|
||||
const oversize = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(512).fill(0x41));
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||
body: oversize,
|
||||
duplex: "half",
|
||||
headers: { cookie: ownerCookie, origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
}, 30_000);
|
||||
|
||||
it("keeps the server healthy after an oversized upload", async () => {
|
||||
// The socket was released rather than left draining, so the very next request is served
|
||||
// normally.
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
|
||||
});
|
||||
|
||||
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
@@ -2506,6 +2989,106 @@ describe.sequential("live API — published module UI artifact", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — the built Dashboard on the API's own origin", () => {
|
||||
it("serves the built shell's index.html at /, with only its API origin written in, and its exact script, and nothing beside them", async () => {
|
||||
const database = await createDisposableDatabase("dashboard_host");
|
||||
await applyCoreMigrations(database.url);
|
||||
const running = await startMigratedApi(database.url);
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
const builtHtml = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||
const builtScript = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||
|
||||
const page = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`);
|
||||
const html = await page.text();
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(page.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(page.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
// The built page carries one empty API-origin element; the API fills in its own origin
|
||||
// (the identity base URL) and changes nothing else (ADR 0045).
|
||||
expect(builtHtml.split(DASHBOARD_API_ORIGIN_PLACEHOLDER)).toHaveLength(2);
|
||||
expect(html).toBe(
|
||||
builtHtml.replace(
|
||||
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||
`<meta content="${identityBaseUrl}" name="ekklesia-api-origin" />`,
|
||||
),
|
||||
);
|
||||
expect(html).toContain('id="ekklesia-host"');
|
||||
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
|
||||
|
||||
const revalidated = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`, {
|
||||
headers: { "if-none-match": page.headers.get("etag") ?? "" },
|
||||
});
|
||||
expect(revalidated.status).toBe(304);
|
||||
|
||||
const script = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}`);
|
||||
expect(script.status).toBe(200);
|
||||
expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(await script.text()).toBe(builtScript);
|
||||
|
||||
// The source map sits beside the script on disk and is never published.
|
||||
const sourceMap = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}.map`);
|
||||
expect(sourceMap.status).toBe(404);
|
||||
expect(await sourceMap.json()).toEqual({ code: "NOT_FOUND" });
|
||||
} finally {
|
||||
await running.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — health", () => {
|
||||
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
|
||||
const database = await createDisposableDatabase("health");
|
||||
await applyCoreMigrations(database.url);
|
||||
const running = await startMigratedApi(database.url);
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
|
||||
const live = await fetch(`${origin}${HEALTH_LIVE_PATH}`);
|
||||
expect(live.status).toBe(200);
|
||||
expect(live.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await live.json()).toEqual({ status: "live" });
|
||||
|
||||
const ready = await fetch(`${origin}${HEALTH_READY_PATH}`);
|
||||
expect(ready.status).toBe(200);
|
||||
expect(ready.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await ready.json()).toEqual({ status: "ready" });
|
||||
} finally {
|
||||
await running.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("pings through the shared pool, and PostgreSQL itself cancels a statement that outlives the ping's transaction-local timeout", async () => {
|
||||
const database = await createDisposableDatabase("health_ping");
|
||||
const handle = createApiDatabase(database.url);
|
||||
try {
|
||||
await expect(handle.ping(READINESS_DATABASE_TIMEOUT_MS)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
// The ping's exact statement shape, with a statement that cannot finish inside its timeout: the
|
||||
// server cancels it (query_canceled) long before the sleep would end, on a client that has no
|
||||
// client-side timeout of its own.
|
||||
const client = new Client({ connectionString: database.url });
|
||||
await client.connect();
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await expect(
|
||||
client.query(
|
||||
"BEGIN READ ONLY; SET LOCAL statement_timeout = 50; SELECT pg_sleep(5); COMMIT",
|
||||
),
|
||||
).rejects.toMatchObject({ code: "57014" });
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — public signup is disabled by default", () => {
|
||||
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
||||
const database = await createDisposableDatabase("signup_disabled");
|
||||
@@ -3037,6 +3620,7 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
||||
permissions: [
|
||||
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
|
||||
{ baseline: "public", description: "View calendar state.", id: "calendar.state.view" },
|
||||
],
|
||||
});
|
||||
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
@@ -3046,6 +3630,11 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
description: "Read announcements.",
|
||||
id: "announcements.events.read",
|
||||
},
|
||||
{
|
||||
baseline: "public",
|
||||
description: "View announcement state.",
|
||||
id: "announcements.state.view",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -3056,6 +3645,8 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
const running = await startConfiguredApi(database.url, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
const realtimeAbort = new AbortController();
|
||||
let calendarReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
|
||||
@@ -3064,6 +3655,18 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
expect(recorder.events).toContain("calendar:start");
|
||||
const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`);
|
||||
expect(calendarRoute.status).toBe(200);
|
||||
await withTransportDeadline(
|
||||
(async () => {
|
||||
const calendarRealtime = await fetch(`${origin}/api/v1/module-realtime/calendar/state`, {
|
||||
signal: realtimeAbort.signal,
|
||||
});
|
||||
expect(calendarRealtime.status).toBe(200);
|
||||
calendarReader = calendarRealtime.body?.getReader();
|
||||
const calendarSnapshot = await calendarReader?.read();
|
||||
expect(new TextDecoder().decode(calendarSnapshot?.value)).toContain("event: snapshot");
|
||||
})(),
|
||||
() => realtimeAbort.abort(),
|
||||
);
|
||||
|
||||
// The disabled module ran nothing at all. Not "registered but skipped", not "started and
|
||||
// gated per request" — its compiled entrypoint was never imported, so no hook could run.
|
||||
@@ -3076,12 +3679,25 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
const disabledRoute = await fetch(`${origin}/api/v1/modules/announcements/events/abc`);
|
||||
expect(disabledRoute.status).toBe(404);
|
||||
expect(await disabledRoute.json()).toEqual({ code: "NOT_FOUND" });
|
||||
const disabledRealtime = await fetch(`${origin}/api/v1/module-realtime/announcements/state`);
|
||||
expect(disabledRealtime.status).toBe(404);
|
||||
expect(await disabledRealtime.json()).toEqual({ code: "NOT_FOUND" });
|
||||
|
||||
// Shutdown never invokes a stop hook for a module that never started.
|
||||
await running.shutdown();
|
||||
const streamClosed = calendarReader?.read();
|
||||
const shutdown = running.shutdown();
|
||||
await expect(
|
||||
withTransportDeadline(
|
||||
streamClosed ?? Promise.reject(harnessError("The realtime stream was unavailable.")),
|
||||
() => realtimeAbort.abort(),
|
||||
),
|
||||
).resolves.toEqual({ done: true, value: undefined });
|
||||
await shutdown;
|
||||
expect(recorder.events).toContain("calendar:stop");
|
||||
expect(recorder.events).not.toContain("announcements:stop");
|
||||
} finally {
|
||||
realtimeAbort.abort();
|
||||
await calendarReader?.cancel().catch(() => undefined);
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
@@ -3093,6 +3709,13 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
await createFixtureModule(modulesDirectory, "storage", recorder);
|
||||
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
permissions: [
|
||||
{
|
||||
baseline: "public",
|
||||
description: "View announcement state.",
|
||||
id: "announcements.state.view",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const database = await createDisposableDatabase("lifecycle_block");
|
||||
@@ -3151,6 +3774,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain(modulesDirectory);
|
||||
expect(raw).not.toContain("dist/");
|
||||
const blockedRealtime = await fetch(`${origin}/api/v1/module-realtime/announcements/state`);
|
||||
expect(blockedRealtime.status).toBe(404);
|
||||
expect(await blockedRealtime.json()).toEqual({ code: "NOT_FOUND" });
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
@@ -3384,11 +4010,15 @@ describe.sequential("live API — bounded HTTP drain and shutdown", () => {
|
||||
expect(raw).toContain("HTTP/1.1 200");
|
||||
expect(raw).toContain('"gated":true');
|
||||
|
||||
// Explicit ordering: the handler finished and the module stopped, in that order.
|
||||
// Explicit ordering: the already-running command can publish its committed state while HTTP
|
||||
// drains, then the handler finishes and only afterward does its module stop.
|
||||
const published = recorder.events.indexOf("drainorder:handler-published");
|
||||
const finished = recorder.events.indexOf("drainorder:handler-finished");
|
||||
const stopped = recorder.events.indexOf("drainorder:stop");
|
||||
expect(published).toBeGreaterThanOrEqual(0);
|
||||
expect(finished).toBeGreaterThanOrEqual(0);
|
||||
expect(stopped).toBeGreaterThanOrEqual(0);
|
||||
expect(published).toBeLessThan(finished);
|
||||
expect(finished).toBeLessThan(stopped);
|
||||
} finally {
|
||||
gate.release();
|
||||
|
||||
+394
-6
@@ -3,25 +3,38 @@ import {
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createPermissionRegistry,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleUiPublicationRegistry,
|
||||
StagedModuleRealtimeRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||
import {
|
||||
type CreateApiAppAuthorizationOptions,
|
||||
type CreateApiAppOptions,
|
||||
createApiApp as createApiAppWithModules,
|
||||
} from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
@@ -58,6 +71,12 @@ function emptyModuleUiPublication(): ModuleUiPublicationRegistry {
|
||||
return createModuleUiPublicationRegistry([]);
|
||||
}
|
||||
|
||||
/** Most application-wiring cases exercise no modules. Keep that fact explicit in one test helper
|
||||
* while production composition still requires the realtime registry with no implicit fallback. */
|
||||
function createApiApp(options: Omit<CreateApiAppOptions, "moduleRealtime">) {
|
||||
return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY });
|
||||
}
|
||||
|
||||
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
||||
* specific decision or store behavior override the relevant method explicitly. The registry
|
||||
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
|
||||
@@ -85,6 +104,23 @@ function fakeAuthorization(overrides: {
|
||||
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
|
||||
}
|
||||
|
||||
function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
||||
return createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
}
|
||||
|
||||
describe("Ekklesia API Hono application", () => {
|
||||
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
@@ -95,6 +131,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -104,10 +142,222 @@ describe("Ekklesia API Hono application", () => {
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("auth-ok");
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces only a successful email sign-in body without exposing Better Auth's token or user", async () => {
|
||||
const nativeCookie =
|
||||
"__Secure-better-auth.session_token=opaque-cookie-value; Path=/; HttpOnly; Secure; SameSite=Lax";
|
||||
const nativeBody = JSON.stringify({
|
||||
token: "javascript-visible-session-token",
|
||||
user: { email: "[email protected]", id: "user_123", name: "Owner" },
|
||||
});
|
||||
const nativeResponse = new Response(nativeBody, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": trustedOrigin,
|
||||
"Content-Length": "4096",
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": nativeCookie,
|
||||
"X-Native-Auth-Header": "preserved",
|
||||
},
|
||||
status: 201,
|
||||
statusText: "Signed In",
|
||||
});
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(nativeResponse),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const visibleBody = await response.text();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.statusText).toBe("Signed In");
|
||||
expect(JSON.parse(visibleBody)).toEqual({ ok: true });
|
||||
expect(visibleBody).not.toContain("token");
|
||||
expect(visibleBody).not.toContain("user");
|
||||
expect(visibleBody).not.toContain("session");
|
||||
expect(visibleBody).not.toContain("[email protected]");
|
||||
expect(response.headers.get("set-cookie")).toBe(nativeCookie);
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("content-type")).toBe("application/json; charset=UTF-8");
|
||||
expect(response.headers.get("content-length")).not.toBe("4096");
|
||||
expect(response.headers.get("x-native-auth-header")).toBe("preserved");
|
||||
expect(nativeResponse.bodyUsed).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/api/auth/%73ign-in/email",
|
||||
"/api/auth/sign-in%2Femail",
|
||||
])("normalizes the router-equivalent encoded sign-in path %s", async (path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
token: "javascript-visible-session-token",
|
||||
user: { email: "[email protected]" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request(path, { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves a failed email sign-in response body and status unchanged", async () => {
|
||||
const nativeFailure = JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" });
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||
new Response(nativeFailure, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.statusText).toBe("Unauthorized");
|
||||
expect(await response.text()).toBe(nativeFailure);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a GET to the sign-in path", "GET", "/api/auth/sign-in/email"],
|
||||
["a sign-out POST", "POST", "/api/auth/sign-out"],
|
||||
["a trailing-slash sign-in POST", "POST", "/api/auth/sign-in/email/"],
|
||||
])("leaves %s on Better Auth's native success contract", async (_label, method, path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("native-auth-body", { status: 200 })),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request(path, { method });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("native-auth-body");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
describe("unused native session-token endpoints", () => {
|
||||
const disabledRequests = [
|
||||
["GET", "/api/auth/get-session"],
|
||||
["POST", "/api/auth/get-session"],
|
||||
["GET", "/api/auth/list-sessions"],
|
||||
["POST", "/api/auth/update-session"],
|
||||
["POST", "/api/auth/change-password"],
|
||||
] as const;
|
||||
|
||||
it.each(
|
||||
disabledRequests,
|
||||
)("returns a fixed 404 for %s %s, including query and trailing-slash spellings, without invoking Better Auth", async (method, path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
session: { token: "javascript-visible-session-token" },
|
||||
token: "another-session-token",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
for (const requestPath of [path, `${path}?include=session`, `${path}/`]) {
|
||||
const response = await app.request(requestPath, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method,
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(JSON.parse(body)).toEqual({ code: "NOT_FOUND" });
|
||||
expect(body).not.toContain("token");
|
||||
expect(body).not.toContain("session");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
}
|
||||
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/api/auth/%67et-session"],
|
||||
["GET", "/api/auth/%6Cist-sessions"],
|
||||
["POST", "/api/auth/%75pdate-session"],
|
||||
["POST", "/api/auth/change-password%2F"],
|
||||
["GET", "/api/auth/get-session%2F"],
|
||||
["GET", "/api/auth%2Fget-session"],
|
||||
["GET", "/api/auth/get-session%"],
|
||||
])("does not delegate the encoded or malformed spelling %s %s", async (method, path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ session: { token: "exposed" } })),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request(path, { method });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies an oversized update-session body before the body limiter or Better Auth reads it", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request("/api/auth/update-session", {
|
||||
body: "x",
|
||||
headers: {
|
||||
"content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["POST", "/api/auth/list-sessions"],
|
||||
["GET", "/api/auth/update-session"],
|
||||
["GET", "/api/auth/change-password"],
|
||||
["GET", "/api/auth/list-sessions/export"],
|
||||
])("leaves the neighbouring non-denied request %s %s on the native contract", async (method, path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("native-method-response", { status: 200 })),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request(path, { method });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("native-method-response");
|
||||
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
@@ -117,6 +367,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -140,6 +392,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -165,6 +419,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -192,6 +448,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -219,6 +477,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -243,6 +503,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -269,6 +531,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -278,7 +542,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("auth-ok");
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
@@ -292,6 +556,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -317,6 +583,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -349,6 +617,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -382,6 +652,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -420,6 +692,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -440,6 +714,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -462,6 +738,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -483,6 +761,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -502,6 +782,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -528,6 +810,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -557,6 +841,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -589,6 +875,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: evaluatorAuthorize },
|
||||
@@ -612,6 +900,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -626,3 +916,101 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||
|
||||
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
|
||||
* it: the same composition a Dashboard on another origin reaches. */
|
||||
function realtimeApp() {
|
||||
const staged = new StagedModuleRealtimeRegistry();
|
||||
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||
registration.realtime.channel({ channel: "state", permission });
|
||||
registration.commit();
|
||||
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
|
||||
const moduleRealtime = staged.publish();
|
||||
const app = createApiAppWithModules({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleRealtime,
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
|
||||
registry: createPermissionRegistry([
|
||||
...CORE_PERMISSION_DECLARATIONS,
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: permission,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
runtime: fakeRuntime({
|
||||
resolvePrincipal: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createAuthenticatedPrincipal(
|
||||
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
|
||||
),
|
||||
),
|
||||
}),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, moduleRealtime };
|
||||
}
|
||||
|
||||
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
|
||||
const { app, moduleRealtime } = realtimeApp();
|
||||
|
||||
const response = await app.request(endpoint, {
|
||||
headers: { cookie: "session=1", origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
expect(response.headers.get("vary")).toContain("Origin");
|
||||
await response.body?.cancel();
|
||||
moduleRealtime.closeSubscriptions();
|
||||
});
|
||||
|
||||
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
|
||||
const { app, moduleRealtime } = realtimeApp();
|
||||
|
||||
const response = await app.request(endpoint, {
|
||||
headers: { cookie: "session=1", origin: untrustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
await response.body?.cancel();
|
||||
moduleRealtime.closeSubscriptions();
|
||||
});
|
||||
|
||||
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
|
||||
const app = createAuthWiringApp(fakeRuntime());
|
||||
|
||||
const response = await app.request("/api/v1/principal", {
|
||||
headers: {
|
||||
"access-control-request-headers": "content-type, x-module-custom",
|
||||
"access-control-request-method": "PATCH",
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
|
||||
"content-type",
|
||||
);
|
||||
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
|
||||
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
||||
|
||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||
|
||||
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
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,
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
@@ -334,3 +337,24 @@ describe("API runtime configuration", () => {
|
||||
expect(publicText).not.toContain("db.internal.example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the API's trusted browser origins (ADR 0045)", () => {
|
||||
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
|
||||
const origins = apiTrustedOrigins({
|
||||
baseUrl: "https://api.example.test",
|
||||
trustedOrigins: ["https://dash.example.test"],
|
||||
});
|
||||
|
||||
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
|
||||
expect(Object.isFrozen(origins)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
|
||||
expect(
|
||||
apiTrustedOrigins({
|
||||
baseUrl: "https://ekklesia.example.test",
|
||||
trustedOrigins: ["https://ekklesia.example.test"],
|
||||
}),
|
||||
).toEqual(["https://ekklesia.example.test"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||
dashboardHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/index.html",
|
||||
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||
DashboardHostAssetError,
|
||||
loadDashboardHostAssets,
|
||||
renderDashboardPage,
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
|
||||
const API_ORIGIN = "https://api.example.test";
|
||||
|
||||
function sha256Etag(text: string): string {
|
||||
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||
}
|
||||
|
||||
describe("Dashboard host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||
const script = "export const dashboardHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
|
||||
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||
]);
|
||||
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||
expect(assets).toEqual({
|
||||
html: rendered,
|
||||
htmlEtag: sha256Etag(rendered),
|
||||
script,
|
||||
scriptEtag: sha256Etag(script),
|
||||
});
|
||||
expect(Object.isFrozen(assets)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||
readFileMock.mockRejectedValueOnce(
|
||||
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||
);
|
||||
|
||||
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||
expect(failure).toMatchObject({
|
||||
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||
message: "The Dashboard host browser assets are unavailable.",
|
||||
name: "DashboardHostAssetError",
|
||||
});
|
||||
expect((failure as Error).stack).toBe(
|
||||
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||
);
|
||||
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||
expect(publicText).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails when the script is missing even though the page was read", async () => {
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
] as const) {
|
||||
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
|
||||
for (const html of [
|
||||
"<!doctype html>",
|
||||
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
|
||||
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||
]) {
|
||||
readFileMock.mockReset();
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard page API origin (ADR 0045)", () => {
|
||||
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
|
||||
|
||||
it("writes the API's origin into the one placeholder, in either topology", () => {
|
||||
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
|
||||
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
|
||||
);
|
||||
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
|
||||
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://api.example.test/",
|
||||
"https://api.example.test/api",
|
||||
'https://api.example.test"><script>',
|
||||
"javascript:alert(1)",
|
||||
"",
|
||||
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
|
||||
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
createPermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
|
||||
// while answering a request — the hallmark of a static directory — fails these tests.
|
||||
const readFileMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
type DashboardHostAssets,
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
DashboardHostConfigurationError,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const modulePermission = parsePermissionId("dashboard.page.read");
|
||||
|
||||
const PAGE_HTML =
|
||||
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
|
||||
const SCRIPT = "export const dashboard = 'built shell';\n";
|
||||
|
||||
function sha256Etag(text: string): string {
|
||||
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||
}
|
||||
|
||||
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||
|
||||
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeStore(): AuthorizationStore {
|
||||
return {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
options: {
|
||||
readonly assets?: DashboardHostAssets;
|
||||
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||
} = {},
|
||||
) {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: createPermissionRegistry([
|
||||
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||
]),
|
||||
store: fakeStore(),
|
||||
},
|
||||
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: fakeModuleServices(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, evaluator, runtime };
|
||||
}
|
||||
|
||||
function expectPageSecurityHeaders(response: Response): void {
|
||||
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
}
|
||||
|
||||
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
|
||||
expect(response.status, label).toBe(404);
|
||||
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
|
||||
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
describe("GET / (the Dashboard page)", () => {
|
||||
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(PAGE_HTML);
|
||||
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||
expectPageSecurityHeaders(response);
|
||||
// The one CSP directive is the only one: the full policy is a separate decision.
|
||||
expect(response.headers.get("content-security-policy")).not.toContain(";");
|
||||
});
|
||||
|
||||
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(PAGE_HTML);
|
||||
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||
});
|
||||
|
||||
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
|
||||
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||
headers: { "If-None-Match": ifNoneMatch },
|
||||
});
|
||||
|
||||
expect(response.status, ifNoneMatch).toBe(304);
|
||||
expect(await response.text(), ifNoneMatch).toBe("");
|
||||
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expectPageSecurityHeaders(response);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the full page again when the browser holds a different build's validator", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||
headers: { "If-None-Match": sha256Etag("an older build") },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(PAGE_HTML);
|
||||
});
|
||||
|
||||
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
|
||||
const { app, evaluator, runtime } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(SCRIPT);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(304);
|
||||
expect(await response.text()).toBe("");
|
||||
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
});
|
||||
|
||||
it("does not accept the page's validator as the script's", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": PAGE_ETAG },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard host boundaries", () => {
|
||||
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of [
|
||||
"/index.html",
|
||||
"/app.js",
|
||||
"/app.js.map",
|
||||
"/assets/ekklesia-dashboard/app.js.map",
|
||||
"/assets/ekklesia-dashboard/",
|
||||
"/assets/ekklesia-dashboard",
|
||||
"/assets/ekklesia-dashboard/app.js/",
|
||||
"/assets/ekklesia-dashboard/index.html",
|
||||
"/assets/",
|
||||
"/assets/ekklesia-dashboard/APP.JS",
|
||||
"/dashboard",
|
||||
"/login",
|
||||
]) {
|
||||
await expectSanitizedNotFound(await app.request(path), path);
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of [
|
||||
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
|
||||
"/assets/ekklesia-dashboard/..%2fapp.js.map",
|
||||
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
|
||||
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
|
||||
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
|
||||
"/%2e%2e/%2e%2e/etc/passwd",
|
||||
"/../../etc/passwd",
|
||||
"//assets/ekklesia-dashboard/app.js",
|
||||
"/assets//ekklesia-dashboard/app.js",
|
||||
"/assets/ekklesia-dashboard/app.js%00.map",
|
||||
]) {
|
||||
const response = await app.request(path);
|
||||
expect(await response.text(), path).not.toContain(SCRIPT);
|
||||
expect(response.status, path).toBe(404);
|
||||
}
|
||||
expect(readFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never touches the filesystem while answering, even across every served path", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||
await app.request("/assets/ekklesia-dashboard/app.js.map");
|
||||
|
||||
expect(readFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
|
||||
expect(head.status).toBe(200);
|
||||
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
|
||||
expect(await head.text()).toBe("");
|
||||
|
||||
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
|
||||
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||
async () => new Response("module", { status: 200 }),
|
||||
);
|
||||
const { app } = buildApp({
|
||||
moduleRoutes: [
|
||||
Object.freeze({
|
||||
bodyMode: "buffered",
|
||||
handler: moduleHandler,
|
||||
maxBodyBytes: 1024,
|
||||
method: "GET",
|
||||
moduleId: "dashboard",
|
||||
path: "/",
|
||||
permission: modulePermission,
|
||||
}) as RegisteredModuleHttpRoute,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
|
||||
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
|
||||
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
|
||||
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
|
||||
// No display is published here, so the display host's script is absent rather than answered
|
||||
// by the Dashboard's.
|
||||
await expectSanitizedNotFound(
|
||||
await app.request(DISPLAY_HOST_SCRIPT_PATH),
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
);
|
||||
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
|
||||
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
|
||||
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||
|
||||
for (const assets of [
|
||||
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||
]) {
|
||||
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -26,6 +27,8 @@ import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
@@ -74,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -138,7 +141,10 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
@@ -267,9 +273,12 @@ describe("Dashboard navigation — reading", () => {
|
||||
|
||||
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
||||
expect(livestream?.module).toEqual({
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||
display: null,
|
||||
id: "livestream",
|
||||
permissions: [],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
||||
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,810 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createAuthorizationDrizzleDatabase,
|
||||
createInitialPlatformOwner,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import {
|
||||
createBootstrapIdentityCreator,
|
||||
createIdentityDrizzleDatabase,
|
||||
} from "@ekklesia/core/identity";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "@ekklesia/core/migrations";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { type RunningApi, startApi } from "../src/server.js";
|
||||
|
||||
/**
|
||||
* Device pairing end to end, against real PostgreSQL and the real HTTP transport (ADR 0036).
|
||||
*
|
||||
* The whole point is that none of this is mocked. A screen with no identity asks to be paired, a
|
||||
* real administrator signs in through the real login transport and approves it, the host issues a
|
||||
* credential in a real `Set-Cookie`, and the resulting device opens a real display descriptor and a
|
||||
* real realtime stream — and is then revoked and loses all of it. Anything short of that would
|
||||
* prove that rows were written, not that a television in a foyer works.
|
||||
*/
|
||||
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>();
|
||||
const trackedDirectories: string[] = [];
|
||||
let databaseCounter = 0;
|
||||
|
||||
const identityBaseUrl = "https://identity.example.test";
|
||||
const dashboardOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const ownerPassword = "correct horse battery staple";
|
||||
const displayPermission = "announcements.slideshow.view";
|
||||
|
||||
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 {
|
||||
return (await client.query<T>(text, [...values])).rows;
|
||||
} catch {
|
||||
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function query<T extends QueryResultRow>(
|
||||
url: string,
|
||||
text: string,
|
||||
values: readonly unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const client = new Client({ connectionString: url });
|
||||
await client.connect();
|
||||
try {
|
||||
return await safeQuery<T>(client, text, values);
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** A display-capable module: one display entrypoint, one restricted permission protecting it, and
|
||||
* one realtime channel gated by that same permission. */
|
||||
async function createDisplayModuleDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
|
||||
trackedDirectories.push(directory);
|
||||
const moduleDirectory = path.join(directory, "announcements");
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: "@test/module-announcements",
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: {
|
||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
id: "announcements",
|
||||
manifestVersion: 7,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: displayPermission,
|
||||
},
|
||||
],
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "announcements",
|
||||
version: "1.0.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
|
||||
},
|
||||
start({ realtime }) {
|
||||
realtime.publish("state", { slides: [] });
|
||||
},
|
||||
stop() {},
|
||||
};\n`,
|
||||
),
|
||||
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
|
||||
]);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function createMigratedDatabase(label: string): Promise<string> {
|
||||
databaseCounter += 1;
|
||||
const name =
|
||||
`ekk_device_${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);
|
||||
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-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 });
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function startPlatform(url: string, modulesDirectory: string): Promise<RunningApi> {
|
||||
const config = createApiRuntimeConfig(
|
||||
{
|
||||
databaseUrl: url,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
// The production posture: no public signup anywhere in this suite.
|
||||
publicSignupEnabled: false,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
{ environment: "development" },
|
||||
);
|
||||
return startApi(config, {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
rateLimitOverride: { max: 1000, window: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
function apiOrigin(running: RunningApi): string {
|
||||
const address = running.server.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw new IntegrationHarnessError("The API integration server has no bound address.");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
/** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-`
|
||||
* whenever the configured base URL is https — which this harness's is. */
|
||||
function cookiePair(response: Response, name: string): string | undefined {
|
||||
for (const header of response.headers.getSetCookie()) {
|
||||
const pair = header.split(";")[0]?.trim() ?? "";
|
||||
const cookieName = pair.slice(0, Math.max(0, pair.indexOf("=")));
|
||||
if (cookieName === name || cookieName.endsWith(name)) {
|
||||
return pair;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setCookieFor(response: Response, name: string): string | undefined {
|
||||
return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`));
|
||||
}
|
||||
|
||||
async function createOwner(url: string, origin: string): Promise<string> {
|
||||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||
const identity = createIdentityDrizzleDatabase(url, {});
|
||||
try {
|
||||
await createInitialPlatformOwner(
|
||||
{
|
||||
authorizationDb: authorization.db,
|
||||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||
identityDb: identity.db,
|
||||
},
|
||||
{ email, name: "Platform Owner", password: ownerPassword },
|
||||
);
|
||||
} finally {
|
||||
await Promise.all([authorization.close(), identity.close()]);
|
||||
}
|
||||
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({ email, password: ownerPassword }),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const cookie = cookiePair(signIn, "better-auth.session_token");
|
||||
if (signIn.status !== 200 || cookie === undefined) {
|
||||
throw new IntegrationHarnessError("The platform owner could not sign in.");
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
interface Platform {
|
||||
readonly api: RunningApi;
|
||||
readonly origin: string;
|
||||
readonly url: string;
|
||||
readonly ownerCookie: string;
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function platform(label: string): Promise<Platform> {
|
||||
const url = await createMigratedDatabase(label);
|
||||
const modulesDirectory = await createDisplayModuleDirectory();
|
||||
const api = await startPlatform(url, modulesDirectory);
|
||||
const origin = apiOrigin(api);
|
||||
const ownerCookie = await createOwner(url, origin);
|
||||
return {
|
||||
api,
|
||||
close: async () => {
|
||||
await api.shutdown();
|
||||
},
|
||||
origin,
|
||||
ownerCookie,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/** The device's own browser: no human session, only whatever cookies the host has set on it. */
|
||||
class DeviceBrowser {
|
||||
#cookies = new Map<string, string>();
|
||||
|
||||
constructor(private readonly origin: string) {}
|
||||
|
||||
absorb(response: Response): void {
|
||||
for (const header of response.headers.getSetCookie()) {
|
||||
const pair = header.split(";")[0] ?? "";
|
||||
const separator = pair.indexOf("=");
|
||||
if (separator === -1) {
|
||||
continue;
|
||||
}
|
||||
const name = pair.slice(0, separator).trim();
|
||||
const value = pair.slice(separator + 1).trim();
|
||||
if (value.length === 0 || /max-age=0/iu.test(header)) {
|
||||
this.#cookies.delete(name);
|
||||
} else {
|
||||
this.#cookies.set(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header(): string {
|
||||
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.#cookies.has(name);
|
||||
}
|
||||
|
||||
overwrite(name: string, value: string): void {
|
||||
this.#cookies.set(name, value);
|
||||
}
|
||||
|
||||
async request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const cookie = this.header();
|
||||
const response = await fetch(`${this.origin}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
origin: dashboardOrigin,
|
||||
...(cookie.length > 0 ? { cookie } : {}),
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
this.absorb(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
post(path: string, body: unknown): Promise<Response> {
|
||||
return this.request(path, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function adminRequest(
|
||||
origin: string,
|
||||
cookie: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
overrides: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
return fetch(`${origin}${path}`, {
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
headers: {
|
||||
cookie,
|
||||
origin: dashboardOrigin,
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
...(overrides.headers as Record<string, string> | undefined),
|
||||
},
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** Drives a screen from "nobody has paired me" to "I hold a device session". */
|
||||
async function pairDevice(
|
||||
platform: Platform,
|
||||
device: DeviceBrowser,
|
||||
deviceName = "Foyer TV",
|
||||
): Promise<{ readonly code: string }> {
|
||||
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||
moduleId: "announcements",
|
||||
});
|
||||
if (requested.status !== 201) {
|
||||
throw new IntegrationHarnessError("The pairing request was refused.");
|
||||
}
|
||||
const { code } = (await requested.json()) as { code: string };
|
||||
const approved = await adminRequest(
|
||||
platform.origin,
|
||||
platform.ownerCookie,
|
||||
"/api/v1/device-pairing/approvals",
|
||||
{ code, deviceName },
|
||||
);
|
||||
if (approved.status !== 200) {
|
||||
throw new IntegrationHarnessError("The pairing approval was refused.");
|
||||
}
|
||||
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||
if (((await claimed.json()) as { status: string }).status !== "paired") {
|
||||
throw new IntegrationHarnessError("The device was not issued a session.");
|
||||
}
|
||||
return { code };
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||
if (admin) {
|
||||
try {
|
||||
for (const name of trackedDatabases) {
|
||||
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await admin.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
for (const directory of trackedDirectories.splice(0)) {
|
||||
await rm(directory, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("display device pairing, end to end", () => {
|
||||
it("takes an unpaired screen all the way to a working display, and back off again", async () => {
|
||||
const running = await platform("e2e");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
// 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it
|
||||
// needs in order to show a code is served.
|
||||
expect((await device.request("/display/announcements")).status).toBe(200);
|
||||
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
||||
|
||||
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
|
||||
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||
moduleId: "announcements",
|
||||
});
|
||||
expect(requested.status).toBe(201);
|
||||
const body = (await requested.json()) as Record<string, unknown>;
|
||||
expect(body.code).toMatch(/^[0-9]{6}$/u);
|
||||
// The claim secret is never in the body — only in a cookie script cannot read.
|
||||
expect(JSON.stringify(body)).not.toContain("claim");
|
||||
const claimCookie = setCookieFor(requested, "ekklesia_device_pairing");
|
||||
expect(claimCookie).toMatch(/HttpOnly/iu);
|
||||
|
||||
// 3. Nothing has happened yet: polling says pending, and the display is still refused.
|
||||
expect(
|
||||
(
|
||||
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
|
||||
status: string;
|
||||
}
|
||||
).status,
|
||||
).toBe("pending");
|
||||
|
||||
// 4. The administrator looks the code up, sees what it wants, and approves it by name.
|
||||
const lookup = await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
"/api/v1/device-pairing/lookups",
|
||||
{ code: body.code },
|
||||
);
|
||||
expect(lookup.status).toBe(200);
|
||||
expect(await lookup.json()).toMatchObject({
|
||||
moduleId: "announcements",
|
||||
permissionId: displayPermission,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
"/api/v1/device-pairing/approvals",
|
||||
{ code: body.code, deviceName: "Foyer TV" },
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
// 5. The screen collects its credential — through Set-Cookie, never through JSON.
|
||||
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||
expect(await claimed.json()).toEqual({ status: "paired" });
|
||||
const sessionCookie = setCookieFor(claimed, "ekklesia_device_session");
|
||||
expect(sessionCookie).toMatch(/HttpOnly/iu);
|
||||
expect(sessionCookie).toMatch(/SameSite=Strict/iu);
|
||||
expect(sessionCookie).toMatch(/Path=\//u);
|
||||
expect(sessionCookie).not.toMatch(/Domain=/iu);
|
||||
// The spent pairing cookie is cleared rather than left on the device.
|
||||
expect(device.has("ekklesia_device_pairing")).toBe(false);
|
||||
expect(device.has("ekklesia_device_session")).toBe(true);
|
||||
|
||||
// 6. The display now works, and reports the device as its viewer.
|
||||
const descriptor = await device.request("/api/v1/module-displays/announcements");
|
||||
expect(descriptor.status).toBe(200);
|
||||
const descriptorBody = (await descriptor.json()) as {
|
||||
viewer: { kind: string; deviceId?: string };
|
||||
module: { permissions: string[] };
|
||||
};
|
||||
expect(descriptorBody.viewer.kind).toBe("device");
|
||||
expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
// Exactly the one permission it was paired for.
|
||||
expect(descriptorBody.module.permissions).toEqual([displayPermission]);
|
||||
|
||||
// 7. Realtime, through the ordinary permission-gated channel.
|
||||
const stream = await device.request("/api/v1/module-realtime/announcements/state", {
|
||||
headers: { accept: "text/event-stream" },
|
||||
});
|
||||
expect(stream.status).toBe(200);
|
||||
expect(stream.headers.get("content-type")).toContain("text/event-stream");
|
||||
await stream.body?.cancel();
|
||||
|
||||
// 8. The device is still nobody as far as the Dashboard is concerned.
|
||||
expect(await (await device.request("/api/v1/principal")).json()).toEqual({
|
||||
kind: "anonymous",
|
||||
});
|
||||
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
|
||||
expect((await device.request("/api/v1/devices")).status).toBe(403);
|
||||
|
||||
// 9. The administrator revokes it.
|
||||
const devices = (await (
|
||||
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
|
||||
expect(devices.devices).toHaveLength(1);
|
||||
expect(devices.devices[0]?.name).toBe("Foyer TV");
|
||||
expect(devices.devices[0]?.permissions).toEqual([displayPermission]);
|
||||
const revoked = await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
`/api/v1/devices/${devices.devices[0]?.id}/revoke`,
|
||||
{},
|
||||
);
|
||||
expect(revoked.status).toBe(204);
|
||||
|
||||
// 10. Everything it could do, it can no longer do — while still holding its cookie.
|
||||
expect(device.has("ekklesia_device_session")).toBe(true);
|
||||
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await device.request("/api/v1/module-realtime/announcements/state", {
|
||||
headers: { accept: "text/event-stream" },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
// And the screen can start over: the page it needs for a new code is still served.
|
||||
expect((await device.request("/display/announcements")).status).toBe(200);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("stores no usable credential, and puts none in any response body", async () => {
|
||||
const running = await platform("secrets");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||
expect(credential.length).toBeGreaterThan(20);
|
||||
|
||||
const stored = await query<{ credential_hash: string; name: string }>(
|
||||
running.url,
|
||||
`select credential_hash, name from ekklesia_core_device`,
|
||||
);
|
||||
expect(stored).toHaveLength(1);
|
||||
// The raw credential is nowhere in the database — only an irreversible digest of it.
|
||||
expect(stored[0]?.credential_hash).not.toContain(credential);
|
||||
expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u);
|
||||
|
||||
const everything = JSON.stringify(
|
||||
await query(running.url, `select * from ekklesia_core_device_pairing_request`),
|
||||
);
|
||||
expect(everything).not.toContain(credential);
|
||||
|
||||
// The descriptor a module's UI can read carries no credential either.
|
||||
const descriptor = await device.request("/api/v1/module-displays/announcements");
|
||||
expect(await descriptor.text()).not.toContain(credential);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("pairing cannot be talked into anything", () => {
|
||||
it("derives the permission from the display, never from the request", async () => {
|
||||
const running = await platform("derive");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
// The browser tries to name its own permission and its own module.
|
||||
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||
moduleId: "announcements",
|
||||
permission: "core.devices.manage",
|
||||
permissionId: "core.devices.manage",
|
||||
});
|
||||
expect(requested.status).toBe(201);
|
||||
const { code } = (await requested.json()) as { code: string };
|
||||
|
||||
const lookup = await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
"/api/v1/device-pairing/lookups",
|
||||
{ code },
|
||||
);
|
||||
// The host recorded the display's own declared permission and ignored the rest.
|
||||
expect(await lookup.json()).toMatchObject({ permissionId: displayPermission });
|
||||
|
||||
const stored = await query<{ permission_id: string }>(
|
||||
running.url,
|
||||
`select permission_id from ekklesia_core_device_pairing_request`,
|
||||
);
|
||||
expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to start pairing for a module that has no display", async () => {
|
||||
const running = await platform("nodisplay");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
for (const moduleId of ["calendar", "core", "../announcements", ""]) {
|
||||
const response = await device.post("/api/v1/device-pairing/requests", { moduleId });
|
||||
expect(response.status, moduleId).toBeGreaterThanOrEqual(400);
|
||||
}
|
||||
expect(
|
||||
await query(running.url, `select id from ekklesia_core_device_pairing_request`),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("issues nothing to someone who knows only the code", async () => {
|
||||
const running = await platform("codeonly");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
const onlooker = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||
moduleId: "announcements",
|
||||
});
|
||||
const { code } = (await requested.json()) as { code: string };
|
||||
await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
"/api/v1/device-pairing/approvals",
|
||||
{
|
||||
code,
|
||||
deviceName: "Foyer TV",
|
||||
},
|
||||
);
|
||||
|
||||
// Someone who read the code off the television — and even guessed the request exists —
|
||||
// holds no claim cookie, so there is nothing for them to collect.
|
||||
const stolen = await onlooker.post("/api/v1/device-pairing/claims", {});
|
||||
expect(stolen.status).toBe(400);
|
||||
expect(onlooker.has("ekklesia_device_session")).toBe(false);
|
||||
|
||||
// The real screen still gets its credential.
|
||||
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||
expect(await claimed.json()).toEqual({ status: "paired" });
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("approval is an administrator's act", () => {
|
||||
it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => {
|
||||
const running = await platform("approve");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||
moduleId: "announcements",
|
||||
});
|
||||
const { code } = (await requested.json()) as { code: string };
|
||||
|
||||
// Anonymous.
|
||||
const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, {
|
||||
body: JSON.stringify({ code, deviceName: "Intruder TV" }),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(anonymous.status).toBe(403);
|
||||
|
||||
// The paired-device cookie is not an administrator either — a device can never approve
|
||||
// another device, or itself.
|
||||
const selfApproval = await device.post("/api/v1/device-pairing/approvals", {
|
||||
code,
|
||||
deviceName: "Self TV",
|
||||
});
|
||||
expect(selfApproval.status).toBe(403);
|
||||
|
||||
// A real administrator from an untrusted origin: CSRF policy applies before anything else.
|
||||
const crossOrigin = await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
"/api/v1/device-pairing/approvals",
|
||||
{ code, deviceName: "Foyer TV" },
|
||||
{ headers: { origin: untrustedOrigin } },
|
||||
);
|
||||
expect(crossOrigin.status).toBe(403);
|
||||
|
||||
// Nothing was approved by any of that.
|
||||
expect(
|
||||
(
|
||||
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
|
||||
status: string;
|
||||
}
|
||||
).status,
|
||||
).toBe("pending");
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot approve the same code twice, and cannot mint two credentials from one", async () => {
|
||||
const running = await platform("single");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||
moduleId: "announcements",
|
||||
});
|
||||
const { code } = (await requested.json()) as { code: string };
|
||||
expect(
|
||||
(
|
||||
await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
"/api/v1/device-pairing/approvals",
|
||||
{ code, deviceName: "Foyer TV" },
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
// A second approval of the same code finds nothing pending.
|
||||
expect(
|
||||
(
|
||||
await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
"/api/v1/device-pairing/approvals",
|
||||
{ code, deviceName: "Second TV" },
|
||||
)
|
||||
).status,
|
||||
).toBe(404);
|
||||
|
||||
// Two claims race for one approved request; exactly one credential may exist.
|
||||
const [first, second] = await Promise.all([
|
||||
device.post("/api/v1/device-pairing/claims", {}),
|
||||
device.post("/api/v1/device-pairing/claims", {}),
|
||||
]);
|
||||
const statuses = [
|
||||
((await first.json()) as { status: string }).status,
|
||||
((await second.json()) as { status: string }).status,
|
||||
];
|
||||
expect(statuses.filter((status) => status === "paired")).toHaveLength(1);
|
||||
expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => {
|
||||
const running = await platform("repair");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device, "Foyer TV");
|
||||
const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||
const listed = (await (
|
||||
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||
).json()) as { devices: Array<{ id: string }> };
|
||||
await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||
{},
|
||||
);
|
||||
|
||||
await pairDevice(running, device, "Foyer TV (replaced)");
|
||||
const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||
expect(secondCredential).not.toBe(firstCredential);
|
||||
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
|
||||
|
||||
// Re-pairing creates a new identity rather than reviving the revoked one, so the old
|
||||
// credential can never come back — presenting it again is simply anonymous.
|
||||
const revokedBrowser = new DeviceBrowser(running.origin);
|
||||
revokedBrowser.overwrite("ekklesia_device_session", firstCredential);
|
||||
expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe(
|
||||
403,
|
||||
);
|
||||
|
||||
const devices = (await (
|
||||
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||
).json()) as { devices: Array<{ revoked: boolean }> };
|
||||
expect(devices.devices).toHaveLength(2);
|
||||
expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||
const running = await platform("mixed");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
const deviceCredential = device.header();
|
||||
|
||||
// A browser presenting both: the person still wins, and stays a person.
|
||||
const both = await fetch(`${running.origin}/api/v1/principal`, {
|
||||
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
|
||||
});
|
||||
expect(((await both.json()) as { kind: string }).kind).toBe("authenticated");
|
||||
|
||||
// And that administrator can still open the display for testing, as a human.
|
||||
const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, {
|
||||
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
|
||||
});
|
||||
expect(descriptor.status).toBe(200);
|
||||
expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe(
|
||||
"authenticated",
|
||||
);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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/display-assets", () => ({
|
||||
displayHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/display/index.html",
|
||||
script: "file:///surface/browser/display/app.js",
|
||||
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
createDisplayHostAssets,
|
||||
DisplayHostAssetError,
|
||||
loadDisplayHostAssets,
|
||||
} from "../src/display-host-assets.js";
|
||||
|
||||
describe("display host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||
const script = "export const displayHost = true;\n";
|
||||
const serviceWorker = "export const displayWorker = true;\n";
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce(script)
|
||||
.mockResolvedValueOnce(serviceWorker);
|
||||
|
||||
const assets = await loadDisplayHostAssets();
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||
]);
|
||||
expect(assets).toEqual({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
serviceWorker,
|
||||
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||
});
|
||||
expect(Object.isFrozen(assets)).toBe(true);
|
||||
});
|
||||
|
||||
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 loadDisplayHostAssets().catch((error: unknown) => error);
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeInstanceOf(DisplayHostAssetError);
|
||||
expect(failure).toMatchObject({
|
||||
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
|
||||
message: "The display host browser assets are unavailable.",
|
||||
name: "DisplayHostAssetError",
|
||||
});
|
||||
expect((failure as Error).stack).toBe(
|
||||
"DisplayHostAssetError: The display 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("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||
for (const [html, script, serviceWorker] of [
|
||||
["", "export {};", "export {};"],
|
||||
["<html></html>", " \n", "export {};"],
|
||||
["<html></html>", "export {};", " \n"],
|
||||
] as const) {
|
||||
expect(() => createDisplayHostAssets(html, script, 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { loadHostConfiguration } from "@ekklesia/core/host";
|
||||
import { createMailProviderConfig } from "@ekklesia/core/mail";
|
||||
import { createStorageProviderConfig } from "@ekklesia/core/storage";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { readConfigFromEnvironment } from "../src/server.js";
|
||||
|
||||
/**
|
||||
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
|
||||
* previously could not: each executable read its own subset of the variables, and Doctor's subset
|
||||
* omitted `S3_*` and the module secrets entirely.
|
||||
*
|
||||
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
|
||||
* validates come from one `loadHostConfiguration` call and produce identical validated
|
||||
* configuration, including every default.
|
||||
*/
|
||||
|
||||
const secretKey = "sk-example-secret-material";
|
||||
const smtpPassword = "sup3r-secret-relay-password";
|
||||
|
||||
function environment(
|
||||
overrides: Readonly<Record<string, string | undefined>> = {},
|
||||
): Readonly<Record<string, string | undefined>> {
|
||||
return {
|
||||
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
|
||||
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
|
||||
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
|
||||
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
|
||||
NODE_ENV: "production",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const configuredInfrastructure = Object.freeze({
|
||||
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
||||
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
||||
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
|
||||
S3_ENDPOINT: "https://storage.example.org",
|
||||
S3_SECRET_KEY: secretKey,
|
||||
});
|
||||
|
||||
/** Exactly what Doctor validates, from exactly the same load the API performs. */
|
||||
function doctorView(env: Readonly<Record<string, string | undefined>>) {
|
||||
const host = loadHostConfiguration(env);
|
||||
return {
|
||||
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
|
||||
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
|
||||
};
|
||||
}
|
||||
|
||||
function apiView(env: Readonly<Record<string, string | undefined>>) {
|
||||
const { environment: resolved, input } = readConfigFromEnvironment(env);
|
||||
return createApiRuntimeConfig(input, { environment: resolved });
|
||||
}
|
||||
|
||||
describe("host configuration parity between startApi and Doctor", () => {
|
||||
it("derives identical object-storage configuration, defaults included", () => {
|
||||
const env = environment(configuredInfrastructure);
|
||||
|
||||
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
||||
// Named explicitly rather than only compared, so a change to either default fails here loudly.
|
||||
expect(apiView(env).storage).toEqual({
|
||||
accessKey: "AKIAEXAMPLEACCESSID",
|
||||
bucketPrefix: "ekklesia",
|
||||
deliveryEndpoint: "https://storage.example.org",
|
||||
endpoint: "https://storage.example.org",
|
||||
forcePathStyle: true,
|
||||
region: "us-east-1",
|
||||
secretKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives identical outbound-email configuration, defaults included", () => {
|
||||
const env = environment(configuredInfrastructure);
|
||||
|
||||
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
||||
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",
|
||||
port: 587,
|
||||
security: "starttls",
|
||||
});
|
||||
});
|
||||
|
||||
it("agrees that an installation with neither service is complete", () => {
|
||||
const env = environment();
|
||||
|
||||
expect(apiView(env).storage).toBeUndefined();
|
||||
expect(doctorView(env).storage).toBeUndefined();
|
||||
expect(apiView(env).mail).toBeUndefined();
|
||||
expect(doctorView(env).mail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries every storage field the direct-delivery pass established", () => {
|
||||
const env = environment({
|
||||
...configuredInfrastructure,
|
||||
S3_BUCKET_PREFIX: "rogc",
|
||||
S3_DELIVERY_ENDPOINT: "https://files.example.org",
|
||||
S3_FORCE_PATH_STYLE: "false",
|
||||
S3_REGION: "eu-west-1",
|
||||
});
|
||||
|
||||
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
||||
expect(apiView(env).storage).toMatchObject({
|
||||
bucketPrefix: "rogc",
|
||||
deliveryEndpoint: "https://files.example.org",
|
||||
forcePathStyle: false,
|
||||
region: "eu-west-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects the same malformed environment with the same sanitized code", () => {
|
||||
for (const [overrides, code] of [
|
||||
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
|
||||
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
|
||||
[
|
||||
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
|
||||
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
|
||||
],
|
||||
[
|
||||
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
|
||||
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
||||
],
|
||||
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
||||
[
|
||||
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
|
||||
"MAIL_HELO_NAME_INVALID",
|
||||
],
|
||||
] as const) {
|
||||
const env = environment(overrides);
|
||||
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
||||
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves module secrets from the same source the runtime is given", () => {
|
||||
const env = environment({
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
||||
});
|
||||
|
||||
expect(
|
||||
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
|
||||
).toBe("module-secret-material");
|
||||
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
|
||||
"module-secret-material",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads only this listener's own address and port outside the shared loader", () => {
|
||||
const config = apiView(
|
||||
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
||||
);
|
||||
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.port).toBe(8080);
|
||||
// Defaults, when unset, stay where they always were.
|
||||
const defaults = apiView(environment());
|
||||
expect(defaults.host).toBe("127.0.0.1");
|
||||
expect(defaults.port).toBe(3000);
|
||||
});
|
||||
|
||||
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
|
||||
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
|
||||
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
|
||||
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
|
||||
|
||||
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
|
||||
expect(() => apiView(env)).toThrowError(
|
||||
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,848 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
createAuthorizationDrizzleDatabase,
|
||||
createInitialPlatformOwner,
|
||||
InitialPlatformOwnerError,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import {
|
||||
createBootstrapIdentityCreator,
|
||||
createIdentityDrizzleDatabase,
|
||||
} from "@ekklesia/core/identity";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "@ekklesia/core/migrations";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { type RunningApi, startApi } from "../src/server.js";
|
||||
|
||||
/**
|
||||
* The initial platform-owner bootstrap, against a real PostgreSQL and the real HTTP transport
|
||||
* (ADR 0035).
|
||||
*
|
||||
* The point of this suite is that it does **not** stop at asserting rows. A fresh installation is
|
||||
* bootstrapped and then the resulting account is signed in through the exact browser-facing login
|
||||
* transport Phase 7B Pass 1 established, its principal is read back, and it is used to reach a
|
||||
* platform-owner-protected resource. Anything less would prove the command wrote a user, not that
|
||||
* an operator can actually get into their dashboard — which is the only thing this pass is for.
|
||||
*
|
||||
* Every scenario runs with `publicSignupEnabled: false`, the production posture, so nothing here
|
||||
* can accidentally depend on the signup route the real deployment does not have.
|
||||
*/
|
||||
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 bootstrapCliPath = path.join(
|
||||
repositoryRoot,
|
||||
"core",
|
||||
"dist",
|
||||
"cli",
|
||||
"create-initial-owner.js",
|
||||
);
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
const identityBaseUrl = "https://identity.example.test";
|
||||
const dashboardOrigin = "https://dashboard.example.test";
|
||||
const ownerPassword = "correct horse battery staple";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
databaseCounter += 1;
|
||||
return `ekk_owner_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||
}
|
||||
|
||||
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 createDisposableDatabase(label: string): Promise<string> {
|
||||
const name = makeDatabaseName(label);
|
||||
const admin = await openAdministrativeClient();
|
||||
try {
|
||||
await safeQuery(admin, `create database "${name}"`);
|
||||
} finally {
|
||||
await admin.end().catch(() => undefined);
|
||||
}
|
||||
trackedDatabases.add(name);
|
||||
return databaseUrl(name);
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-owner-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 });
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh, migrated installation with no identity and no owner — exactly what an operator has
|
||||
* after `docker compose up` and `pnpm migrations:apply`. */
|
||||
async function freshInstallation(label: string): Promise<string> {
|
||||
const url = await createDisposableDatabase(label);
|
||||
await applyCoreMigrations(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
interface BootstrapHandles {
|
||||
readonly run: (
|
||||
email: string,
|
||||
password?: string,
|
||||
name?: string,
|
||||
) => ReturnType<typeof createInitialPlatformOwner>;
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Drives the same operation the CLI drives, with the same production composition. */
|
||||
function bootstrapAgainst(url: string): BootstrapHandles {
|
||||
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||
const identity = createIdentityDrizzleDatabase(url, {});
|
||||
return {
|
||||
close: async () => {
|
||||
await Promise.all([
|
||||
authorization.close().catch(() => undefined),
|
||||
identity.close().catch(() => undefined),
|
||||
]);
|
||||
},
|
||||
run: (email, password = ownerPassword, name = "Platform Owner") =>
|
||||
createInitialPlatformOwner(
|
||||
{
|
||||
authorizationDb: authorization.db,
|
||||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||
identityDb: identity.db,
|
||||
},
|
||||
{ email, name, password },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The production posture: public signup disabled, exactly as a real deployment must run — and, just
|
||||
* 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,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
publicSignupEnabled: false,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
{ environment: "development" },
|
||||
);
|
||||
}
|
||||
|
||||
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
|
||||
}
|
||||
|
||||
function apiOrigin(running: RunningApi): string {
|
||||
const address = running.server.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw new IntegrationHarnessError("The API integration server has no bound address.");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
function firstCookiePair(setCookieHeader: string): string {
|
||||
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
async function signIn(
|
||||
origin: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ cookiePair?: string; response: Response }> {
|
||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({ email, password }),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
return setCookie === null ? { response } : { cookiePair: firstCookiePair(setCookie), response };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface CliOutcome {
|
||||
readonly code: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
/** Runs the real compiled CLI as a real child process, with the password on stdin and never in
|
||||
* `argv`. */
|
||||
async function runBootstrapCli(
|
||||
databaseUrl: string | undefined,
|
||||
args: readonly string[],
|
||||
password: string | undefined,
|
||||
): Promise<CliOutcome> {
|
||||
const environment: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
NODE_ENV: "development",
|
||||
};
|
||||
if (databaseUrl === undefined) {
|
||||
delete environment.DATABASE_URL;
|
||||
} else {
|
||||
environment.DATABASE_URL = databaseUrl;
|
||||
}
|
||||
const child = execFileAsync(process.execPath, [bootstrapCliPath, ...args], {
|
||||
env: environment,
|
||||
});
|
||||
child.child.stdin?.end(password ?? "");
|
||||
try {
|
||||
const { stdout, stderr } = await child;
|
||||
return { code: 0, stderr, stdout };
|
||||
} catch (error) {
|
||||
const failure = error as { code?: number; stdout?: string; stderr?: string };
|
||||
return {
|
||||
code: typeof failure.code === "number" ? failure.code : 1,
|
||||
stderr: failure.stderr ?? "",
|
||||
stdout: failure.stdout ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function countRows(url: string, table: string): Promise<number> {
|
||||
const client = new Client({ connectionString: url });
|
||||
await client.connect();
|
||||
try {
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
client,
|
||||
`select count(*)::text from "${table}"`,
|
||||
);
|
||||
return Number(rows[0]?.count ?? "0");
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("initial platform-owner bootstrap", () => {
|
||||
it("takes a fresh installation all the way to an authenticated owner in the browser", async () => {
|
||||
const url = await freshInstallation("browserproof");
|
||||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||
|
||||
const bootstrap = bootstrapAgainst(url);
|
||||
let created: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
|
||||
try {
|
||||
created = await bootstrap.run(email);
|
||||
} finally {
|
||||
await bootstrap.close();
|
||||
}
|
||||
expect(created.identity).toBe("created");
|
||||
expect(created.email).toBe(email.toLowerCase());
|
||||
|
||||
const api = await startApiWithoutSignup(url);
|
||||
try {
|
||||
const origin = apiOrigin(api);
|
||||
|
||||
// 1. The exact browser-facing login transport, on a deployment where signup does not exist.
|
||||
const session = await signIn(origin, email, ownerPassword);
|
||||
expect(session.response.status).toBe(200);
|
||||
expect(session.cookiePair).toBeDefined();
|
||||
const cookie = session.cookiePair as string;
|
||||
|
||||
// 2. The principal the browser bootstrap reads.
|
||||
const principalResponse = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
|
||||
expect(principalResponse.status).toBe(200);
|
||||
const principal = (await principalResponse.json()) as {
|
||||
kind: string;
|
||||
userId?: string;
|
||||
};
|
||||
expect(principal.kind).toBe("authenticated");
|
||||
expect(principal.userId).toBe(created.userId);
|
||||
|
||||
// 3. A platform-owner-protected resource actually opens for them. This is the assertion that
|
||||
// separates "a row was written" from "the operator can run their platform".
|
||||
const grants = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`, {
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(grants.status).toBe(200);
|
||||
|
||||
// 4. And the same resource is still closed to everyone else.
|
||||
const anonymous = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`);
|
||||
expect(anonymous.status).toBe(403);
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves public signup disabled — the owner exists without a signup route existing", async () => {
|
||||
const url = await freshInstallation("nosignup");
|
||||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||
const bootstrap = bootstrapAgainst(url);
|
||||
try {
|
||||
await bootstrap.run(email);
|
||||
} finally {
|
||||
await bootstrap.close();
|
||||
}
|
||||
|
||||
const api = await startApiWithoutSignup(url);
|
||||
try {
|
||||
const origin = apiOrigin(api);
|
||||
const attempted = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||
body: JSON.stringify({
|
||||
email: `intruder-${randomBytes(4).toString("hex")}@example.test`,
|
||||
name: "Intruder",
|
||||
password: ownerPassword,
|
||||
}),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
// The bootstrap command created an account; it did not open a door for anyone else to.
|
||||
expect(attempted.status).toBe(400);
|
||||
expect(await attempted.text()).toContain("EMAIL_PASSWORD_SIGN_UP_DISABLED");
|
||||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||
|
||||
// And the owner can still sign in, so disabling signup did not disable the account.
|
||||
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
describe("the guard", () => {
|
||||
it("refuses a second bootstrap, changes no password, and creates no second owner", async () => {
|
||||
const url = await freshInstallation("guard");
|
||||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||
const bootstrap = bootstrapAgainst(url);
|
||||
try {
|
||||
await bootstrap.run(email);
|
||||
|
||||
// Same address, different password: must not become a password reset.
|
||||
await expect(bootstrap.run(email, "an entirely different password")).rejects.toMatchObject({
|
||||
code: "INITIAL_OWNER_ALREADY_COMPLETED",
|
||||
});
|
||||
// A different address must not become a second owner.
|
||||
await expect(
|
||||
bootstrap.run(`second-${randomBytes(4).toString("hex")}@example.test`),
|
||||
).rejects.toMatchObject({ code: "INITIAL_OWNER_ALREADY_COMPLETED" });
|
||||
} finally {
|
||||
await bootstrap.close();
|
||||
}
|
||||
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||
// The refused second attempt with a new address created no stray identity: the sentinel is
|
||||
// checked before anything is created, not after.
|
||||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||
|
||||
const api = await startApiWithoutSignup(url);
|
||||
try {
|
||||
const origin = apiOrigin(api);
|
||||
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||
expect(
|
||||
(await signIn(origin, email, "an entirely different password")).response.status,
|
||||
).not.toBe(200);
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation reuses the identity system's own policy", () => {
|
||||
it("rejects an invalid email, and a password outside the 8–128 bound", async () => {
|
||||
const url = await freshInstallation("validation");
|
||||
const bootstrap = bootstrapAgainst(url);
|
||||
try {
|
||||
await expect(bootstrap.run("not-an-email-address")).rejects.toMatchObject({
|
||||
code: "INITIAL_OWNER_EMAIL_INVALID",
|
||||
});
|
||||
await expect(
|
||||
bootstrap.run(`short-${randomBytes(4).toString("hex")}@example.test`, "1234567"),
|
||||
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_SHORT" });
|
||||
await expect(
|
||||
bootstrap.run(`long-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(129)),
|
||||
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_LONG" });
|
||||
|
||||
// Exactly the boundary the browser accepts, so the two can never diverge.
|
||||
await expect(
|
||||
bootstrap.run(`edge-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(128)),
|
||||
).resolves.toMatchObject({ identity: "created" });
|
||||
} finally {
|
||||
await bootstrap.close();
|
||||
}
|
||||
|
||||
// Every rejection happened before anything was written, and the one success is the only row.
|
||||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||
});
|
||||
|
||||
it("never puts the password into an error", async () => {
|
||||
const url = await freshInstallation("nosecrets");
|
||||
const secret = "sup3rSecretBootstrapPassword!";
|
||||
const bootstrap = bootstrapAgainst(url);
|
||||
try {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bootstrap.run("not-an-email-address", secret);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InitialPlatformOwnerError);
|
||||
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||||
expect(rendered).not.toContain(secret);
|
||||
expect(rendered).not.toContain("postgres");
|
||||
expect(rendered).not.toContain("select");
|
||||
} finally {
|
||||
await bootstrap.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("an identity that already exists", () => {
|
||||
it("adopts a usable account and leaves its password untouched", async () => {
|
||||
const url = await freshInstallation("adopt");
|
||||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||
|
||||
// Exactly the partial failure this guards: the identity committed, the grant never ran.
|
||||
const identity = createIdentityDrizzleDatabase(url, {});
|
||||
try {
|
||||
await createBootstrapIdentityCreator(identity.db)({
|
||||
email,
|
||||
name: "Half Finished",
|
||||
password: ownerPassword,
|
||||
});
|
||||
} finally {
|
||||
await identity.close();
|
||||
}
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||
|
||||
const bootstrap = bootstrapAgainst(url);
|
||||
try {
|
||||
const result = await bootstrap.run(email, "a different password than before");
|
||||
expect(result.identity).toBe("adopted");
|
||||
} finally {
|
||||
await bootstrap.close();
|
||||
}
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||
|
||||
const api = await startApiWithoutSignup(url);
|
||||
try {
|
||||
const origin = apiOrigin(api);
|
||||
// Adoption granted ownership. It did not take the account over: the original password still
|
||||
// works and the one supplied to the rerun does not.
|
||||
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||
expect(
|
||||
(await signIn(origin, email, "a different password than before")).response.status,
|
||||
).not.toBe(200);
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a credential-less row rather than consuming bootstrap on an unusable owner", async () => {
|
||||
const url = await freshInstallation("unusable");
|
||||
const client = new Client({ connectionString: url });
|
||||
await client.connect();
|
||||
try {
|
||||
await safeQuery(
|
||||
client,
|
||||
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
|
||||
values ($1, $2, $3, false, now(), now())`,
|
||||
["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Shell Row", "[email protected]"],
|
||||
);
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
|
||||
const bootstrap = bootstrapAgainst(url);
|
||||
try {
|
||||
await expect(bootstrap.run("[email protected]")).rejects.toMatchObject({
|
||||
code: "INITIAL_OWNER_IDENTITY_UNUSABLE",
|
||||
});
|
||||
} finally {
|
||||
await bootstrap.close();
|
||||
}
|
||||
|
||||
// The decisive part: bootstrap is still available. Granting ownership to a row nobody can
|
||||
// authenticate as would have consumed the single use and left the installation unusable with
|
||||
// no second chance.
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partial failure", () => {
|
||||
it("reports a failed grant honestly and stays recoverable by rerunning", async () => {
|
||||
const url = await freshInstallation("partial");
|
||||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||
|
||||
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||
const identity = createIdentityDrizzleDatabase(url, {});
|
||||
try {
|
||||
await expect(
|
||||
createInitialPlatformOwner(
|
||||
{
|
||||
authorizationDb: authorization.db,
|
||||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||
grantOwnership: () => {
|
||||
throw new Error("the grant failed after the identity committed");
|
||||
},
|
||||
identityDb: identity.db,
|
||||
},
|
||||
{ email, name: "Owner", password: ownerPassword },
|
||||
),
|
||||
).rejects.toMatchObject({ code: "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" });
|
||||
} finally {
|
||||
await Promise.all([authorization.close(), identity.close()]);
|
||||
}
|
||||
|
||||
// The identity committed; the grant did not; the single-use sentinel was never consumed.
|
||||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
|
||||
|
||||
// Rerunning the identical command finishes the job, which is the whole recovery story.
|
||||
const retry = bootstrapAgainst(url);
|
||||
try {
|
||||
await expect(retry.run(email)).resolves.toMatchObject({ identity: "adopted" });
|
||||
} finally {
|
||||
await retry.close();
|
||||
}
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrency", () => {
|
||||
it("gives exactly one owner when several bootstraps race", async () => {
|
||||
const url = await freshInstallation("race");
|
||||
const attempts = 5;
|
||||
const handles = Array.from({ length: attempts }, () => bootstrapAgainst(url));
|
||||
try {
|
||||
const outcomes = await Promise.allSettled(
|
||||
handles.map((handle, index) =>
|
||||
handle.run(`racer-${index}-${randomBytes(3).toString("hex")}@example.test`),
|
||||
),
|
||||
);
|
||||
const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled");
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
for (const outcome of outcomes.filter((candidate) => candidate.status === "rejected")) {
|
||||
expect((outcome as PromiseRejectedResult).reason).toMatchObject({
|
||||
code: "INITIAL_OWNER_ALREADY_COMPLETED",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(handles.map((handle) => handle.close()));
|
||||
}
|
||||
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(1);
|
||||
// The losers created nothing at all. Serializing whole invocations — not just the grant — is
|
||||
// what keeps a race from leaving signable-in accounts that own nothing behind.
|
||||
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the command itself", () => {
|
||||
it("succeeds, prints something useful, and leaks nothing", async () => {
|
||||
const url = await freshInstallation("cli");
|
||||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||
const result = await runBootstrapCli(url, ["--email", email], ownerPassword);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("Initial platform owner created successfully.");
|
||||
expect(result.stdout).toContain(email.toLowerCase());
|
||||
const everything = `${result.stdout} ${result.stderr}`;
|
||||
expect(everything).not.toContain(ownerPassword);
|
||||
expect(everything).not.toContain("postgresql://");
|
||||
expect(everything).not.toContain("password");
|
||||
|
||||
const api = await startApiWithoutSignup(url);
|
||||
try {
|
||||
// The command's real product: an operator who can now sign in.
|
||||
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a password supplied on the command line", async () => {
|
||||
const url = await freshInstallation("argv");
|
||||
const result = await runBootstrapCli(
|
||||
url,
|
||||
["--email", "[email protected]", "--password", "hunter2hunter2"],
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain("--password is not supported");
|
||||
expect(await countRows(url, "ekklesia_core_user")).toBe(0);
|
||||
});
|
||||
|
||||
it("fails clearly without a database, and refuses to migrate one itself", async () => {
|
||||
const missing = await runBootstrapCli(
|
||||
undefined,
|
||||
["--email", "[email protected]"],
|
||||
"whatever",
|
||||
);
|
||||
expect(missing.code).toBe(1);
|
||||
expect(missing.stderr).toContain("DATABASE_URL is required.");
|
||||
|
||||
// A database that exists but was never migrated is a deployment step that has not happened,
|
||||
// and this command says so rather than quietly running migrations for a production operator.
|
||||
const unmigrated = await createDisposableDatabase("unmigrated");
|
||||
const result = await runBootstrapCli(
|
||||
unmigrated,
|
||||
["--email", "[email protected]"],
|
||||
ownerPassword,
|
||||
);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||
|
||||
const client = new Client({ connectionString: unmigrated });
|
||||
await client.connect();
|
||||
try {
|
||||
const tables = await safeQuery<{ count: string }>(
|
||||
client,
|
||||
`select count(*)::text from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(Number(tables[0]?.count ?? "0")).toBe(0);
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a second run at the command level too", async () => {
|
||||
const url = await freshInstallation("clirerun");
|
||||
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||
expect((await runBootstrapCli(url, ["--email", email], ownerPassword)).code).toBe(0);
|
||||
|
||||
const second = await runBootstrapCli(url, ["--email", email], "a different password");
|
||||
expect(second.code).toBe(1);
|
||||
expect(second.stderr).toContain("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||
expect(second.stderr).not.toContain("a different password");
|
||||
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,26 @@ const betterAuthMock = vi.hoisted(() => vi.fn());
|
||||
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
||||
const serveMock = vi.hoisted(() => vi.fn());
|
||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
|
||||
// so the loader answers a small consistent snapshot unless a test makes it fail.
|
||||
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||
);
|
||||
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||
});
|
||||
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||
});
|
||||
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
|
||||
// gets the real Hono application; only the one test that deliberately forces an app-construction
|
||||
// failure overrides this with `mockImplementationOnce`.
|
||||
@@ -30,6 +45,13 @@ import {
|
||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
|
||||
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||
import {
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||
|
||||
function validConfig() {
|
||||
@@ -82,7 +104,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -149,6 +171,8 @@ interface WorkingFixtureModuleOptions {
|
||||
* can be exercised deterministically without any sleep. */
|
||||
readonly stopNeverSettles?: boolean;
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
/** Declares one protected display artifact without importing it. */
|
||||
readonly withDisplay?: boolean;
|
||||
}
|
||||
|
||||
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
|
||||
@@ -162,12 +186,43 @@ async function createWorkingFixtureModule(
|
||||
options: WorkingFixtureModuleOptions = {},
|
||||
): Promise<void> {
|
||||
const permissionId = `${id}.ping.read`;
|
||||
const displayPermission = `${id}.display.view`;
|
||||
const permissions = [
|
||||
...(options.withRoute
|
||||
? [{ baseline: "public" as const, description: "Ping.", id: permissionId }]
|
||||
: []),
|
||||
...(options.withDisplay
|
||||
? [
|
||||
{
|
||||
baseline: "restricted" as const,
|
||||
description: "View the display.",
|
||||
id: displayPermission,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
await createFixtureModule(modulesDirectory, id, {
|
||||
permissions: options.withRoute
|
||||
? [{ baseline: "public", description: "Ping.", id: permissionId }]
|
||||
: [],
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(options.withDisplay
|
||||
? {
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: displayPermission,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
permissions,
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
});
|
||||
if (options.withDisplay) {
|
||||
await mkdir(path.join(modulesDirectory, id, "dist/display"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, id, "dist/display/index.js"),
|
||||
'throw new Error("display artifact must never execute during server startup");\n',
|
||||
);
|
||||
}
|
||||
const routeSource = options.withRoute
|
||||
? `http.route({
|
||||
method: "GET",
|
||||
@@ -215,12 +270,13 @@ function fakeDiscoveredModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions,
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
@@ -407,6 +463,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
|
||||
const query = emptyQuery();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig());
|
||||
query.mockClear();
|
||||
|
||||
const ready = await running.app.request(HEALTH_READY_PATH);
|
||||
expect(ready.status).toBe(200);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
|
||||
// cancels a stalled ping rather than the route merely abandoning it.
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
|
||||
);
|
||||
|
||||
const shutdown = running.shutdown();
|
||||
const draining = await running.app.request(HEALTH_READY_PATH);
|
||||
expect(draining.status).toBe(503);
|
||||
expect(await draining.json()).toEqual({ code: "NOT_READY" });
|
||||
// Draining is not dead: liveness still answers while in-flight requests finish.
|
||||
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
await shutdown;
|
||||
});
|
||||
|
||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
@@ -942,6 +1028,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
serveMock.mockReset();
|
||||
loadDisplayHostAssetsMock.mockReset();
|
||||
vi.mocked(createApiApp).mockClear();
|
||||
});
|
||||
|
||||
@@ -954,6 +1041,70 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
return { poolEnd };
|
||||
}
|
||||
|
||||
it("fails on missing display-host assets before importing module code and still closes startup resources", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||
withDisplay: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
loadDisplayHostAssetsMock.mockRejectedValueOnce(new DisplayHostAssetError());
|
||||
|
||||
const failure = await startApi(validConfig(), {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
}).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
|
||||
message: "The display host browser assets are unavailable.",
|
||||
name: "DisplayHostAssetError",
|
||||
});
|
||||
expect((failure as Error).stack).toBe(
|
||||
"DisplayHostAssetError: The display host browser assets are unavailable.",
|
||||
);
|
||||
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
|
||||
expect(recorder.events).toEqual([]);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
|
||||
|
||||
const failure = await startApi(validConfig(), {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
}).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||
message: "The Dashboard host browser assets are unavailable.",
|
||||
name: "DashboardHostAssetError",
|
||||
});
|
||||
expect((failure as Error).stack).toBe(
|
||||
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||
);
|
||||
expect(recorder.events).toEqual([]);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
|
||||
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ModuleConfigurationRecord,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
@@ -34,6 +35,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import {
|
||||
createFakeModuleSecretSource,
|
||||
createInMemoryModuleConfigurationStore,
|
||||
@@ -105,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -188,6 +191,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: {
|
||||
bootSettings,
|
||||
discoveredModules: modules,
|
||||
@@ -205,6 +210,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: lifecycleStore,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { createApiDatabase } from "../src/database.js";
|
||||
import { startApi, type RunningApi } from "../src/server.js";
|
||||
import { type RunningApi, startApi } from "../src/server.js";
|
||||
|
||||
/**
|
||||
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
|
||||
@@ -223,6 +223,8 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -251,6 +253,8 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
@@ -317,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
|
||||
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> {
|
||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({ email, password }),
|
||||
@@ -505,11 +530,12 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||
|
||||
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");
|
||||
expect(signedUp.userId).toBeDefined();
|
||||
});
|
||||
|
||||
it("authorized read reflects real PostgreSQL state, exposes exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => {
|
||||
it("authorized read reflects real PostgreSQL state, exposes the exact scoped handler context, and the handler's database is reference-equal to start's", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||
@@ -520,15 +546,16 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
.reverse()
|
||||
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||
// The complete handler-context surface, asserted exactly so a new field is a deliberate change
|
||||
// here rather than an unnoticed one: `config` (ADR 0029) and `storage` (ADR 0030) are the two
|
||||
// scoped, per-module additions; everything else a module might want — a Hono Context, the
|
||||
// authorization store, raw PostgreSQL, the storage provider, an endpoint or credential — remains
|
||||
// unreachable.
|
||||
// here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime`
|
||||
// (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono
|
||||
// Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or
|
||||
// credential — remains unreachable.
|
||||
expect(readHandlerEvent?.contextKeys).toEqual([
|
||||
"config",
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
"realtime",
|
||||
"services",
|
||||
"storage",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
DISPLAY_SERVICE_WORKER_PATH,
|
||||
ModuleDisplayConfigurationError,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
} from "../src/module-display-routes.js";
|
||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||
|
||||
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const displayPermission = "announcements.display.view";
|
||||
|
||||
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
||||
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
||||
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
|
||||
);
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly display?: boolean;
|
||||
readonly id?: string;
|
||||
readonly ui?: boolean;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
|
||||
const id = fixture.id ?? "announcements";
|
||||
const version = fixture.version ?? "1.4.2";
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${id}`,
|
||||
type: "module",
|
||||
version,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||
...(fixture.display
|
||||
? {
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: `${id}.display.view`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: fixture.display
|
||||
? [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: `View the ${id} display.`,
|
||||
id: `${id}.display.view`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
version,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
|
||||
);
|
||||
if (fixture.ui) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||
);
|
||||
}
|
||||
if (fixture.display) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/display/index.js"),
|
||||
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InstallOptions extends ModuleFixture {
|
||||
readonly assets?: DisplayHostAssets;
|
||||
readonly evaluator?: AuthorizationEvaluator;
|
||||
readonly granted?: readonly string[];
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
/** The API builds publication from the eligible/running set. `false` models a module that was
|
||||
* discovered but disabled or dependency-blocked before runtime startup. */
|
||||
readonly published?: boolean;
|
||||
readonly withAssets?: boolean;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly app: Hono;
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
}
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
function fakeAuthorizationStore(): 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(),
|
||||
} as unknown as AuthorizationStore;
|
||||
}
|
||||
|
||||
async function install(options: InstallOptions): Promise<Installation> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await createModule(modulesDirectory, options);
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
const granted = new Set(options.granted ?? []);
|
||||
const evaluator =
|
||||
options.evaluator ??
|
||||
createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
||||
platformOwners: { isPlatformOwner: async () => false },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const publishedModules = options.published === false ? [] : modules;
|
||||
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: fakeAuthorizationStore(),
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||
: {}),
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeServices(),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
moduleUi,
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, modules };
|
||||
}
|
||||
|
||||
function signedIn(): EkklesiaPrincipal {
|
||||
return createAuthenticatedPrincipal(viewerId);
|
||||
}
|
||||
|
||||
describe("module display routes — authorization and descriptor", () => {
|
||||
it("gives an unpaired viewer the inert shell but never the descriptor", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
const [page, descriptor] = await Promise.all([
|
||||
app.request(moduleDisplayPageUrl("announcements")),
|
||||
app.request(moduleDisplayDescriptorUrl("announcements")),
|
||||
]);
|
||||
|
||||
// The page is deliberately open: a television nobody has paired has no identity to present, and
|
||||
// a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing —
|
||||
// the same module-agnostic bytes every display gets, with no module id and no data.
|
||||
expect(page.status).toBe(200);
|
||||
expect(await page.text()).toBe(displayHostAssets.html);
|
||||
|
||||
// Authorization is entirely in the descriptor, and it has not moved.
|
||||
expect(descriptor.status).toBe(403);
|
||||
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
});
|
||||
|
||||
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
||||
const { app } = await install({ display: true, principal: signedIn() });
|
||||
|
||||
// Being signed in is not being permitted, and the descriptor is where that is decided.
|
||||
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
|
||||
});
|
||||
|
||||
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
version: "1.4.2",
|
||||
});
|
||||
|
||||
const page = await app.request(moduleDisplayPageUrl("announcements"));
|
||||
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||
|
||||
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-store");
|
||||
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(await page.text()).toBe(displayHostAssets.html);
|
||||
expect(await descriptor.json()).toEqual({
|
||||
viewer: { kind: "authenticated", userId: viewerId },
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||
display: {
|
||||
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||
},
|
||||
id: "announcements",
|
||||
page: { url: moduleDisplayPageUrl("announcements") },
|
||||
permissions: [displayPermission],
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||
version: "1.4.2",
|
||||
},
|
||||
});
|
||||
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||
expect(descriptor.headers.get("content-type")).toContain("application/json");
|
||||
expect(descriptor.headers.get("vary")).toContain("Origin");
|
||||
expect(descriptor.headers.get("vary")).toContain("Cookie");
|
||||
});
|
||||
|
||||
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
|
||||
const authorize = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ allowed: true })
|
||||
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
|
||||
const evaluator: AuthorizationEvaluator = { authorize };
|
||||
const { app } = await install({ display: true, evaluator, principal: signedIn() });
|
||||
|
||||
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||
const body = await response.text();
|
||||
|
||||
expect(authorize).toHaveBeenCalledTimes(2);
|
||||
expect(response.status).toBe(503);
|
||||
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("vary")).toContain("Origin");
|
||||
expect(response.headers.get("vary")).toContain("Cookie");
|
||||
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
|
||||
expect(body).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the outer page stable when the internal artifact version changes", () => {
|
||||
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
|
||||
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module display routes — role and lifecycle separation", () => {
|
||||
it("keeps a display-only module out of the primary UI inventory", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
expect(inventory.status).toBe(200);
|
||||
expect(await inventory.json()).toEqual({ modules: [] });
|
||||
});
|
||||
|
||||
it("does not give a dashboard-only module a display route", async () => {
|
||||
const { app } = await install({ principal: signedIn(), ui: true });
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
expect(inventory.status).toBe(200);
|
||||
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
|
||||
modules: [{ id: "announcements" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes both roles independently when a module declares both", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
ui: true,
|
||||
});
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
modules: 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 () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
published: false,
|
||||
});
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await app.request(
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(404);
|
||||
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("registers only the exact stable page and descriptor paths", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
for (const route of [
|
||||
"/display",
|
||||
"/display/announcements/",
|
||||
"/display/announcements/settings",
|
||||
"/api/v1/module-displays",
|
||||
"/api/v1/module-displays/announcements/",
|
||||
"/api/v1/module-displays/announcements/settings",
|
||||
]) {
|
||||
expect((await app.request(route)).status, route).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails composition when a display exists but its host assets were not supplied", async () => {
|
||||
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
|
||||
ModuleDisplayConfigurationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module display host asset publication", () => {
|
||||
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(displayHostAssets.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("x-content-type-options")).toBe("nosniff");
|
||||
|
||||
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||
});
|
||||
expect(notModified.status).toBe(304);
|
||||
expect(await notModified.text()).toBe("");
|
||||
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
|
||||
expect(notModified.headers.get("cache-control")).toBe("no-cache");
|
||||
|
||||
for (const header of [
|
||||
`W/${displayHostAssets.scriptEtag}`,
|
||||
"*",
|
||||
`"stale", ${displayHostAssets.scriptEtag}`,
|
||||
]) {
|
||||
expect(
|
||||
(
|
||||
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||
headers: { "if-none-match": header },
|
||||
})
|
||||
).status,
|
||||
header,
|
||||
).toBe(304);
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||
headers: { "if-none-match": '"stale"' },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
for (const candidate of [
|
||||
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
|
||||
"/assets/ekklesia-display/index.html",
|
||||
"/assets/ekklesia-display/secret.txt",
|
||||
"/assets/ekklesia-display/../secret.txt",
|
||||
"/api/v1/module-ui/announcements/1.4.2/management",
|
||||
]) {
|
||||
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses identical non-interpolated HTML for every eligible module", async () => {
|
||||
const first = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
});
|
||||
const second = await install({
|
||||
display: true,
|
||||
granted: ["signage.display.view"],
|
||||
id: "signage",
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
|
||||
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
|
||||
expect(firstHtml).toBe(secondHtml);
|
||||
expect(firstHtml).not.toContain("announcements");
|
||||
expect(secondHtml).not.toContain("signage");
|
||||
});
|
||||
|
||||
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",
|
||||
"export const worker = 1;\n",
|
||||
);
|
||||
const mutable = { ...source };
|
||||
const { app } = await install({ assets: mutable, display: true });
|
||||
mutable.html = "<html>mutated secret</html>";
|
||||
mutable.script = "throw new Error('mutated secret');";
|
||||
|
||||
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
|
||||
|
||||
await expect(
|
||||
install({
|
||||
assets: { ...source, scriptEtag: '"stale-validator"' },
|
||||
display: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
|
||||
});
|
||||
|
||||
it("does not publish the generic host script when no eligible display uses it", async () => {
|
||||
const { app } = await install({ ui: true, withAssets: false });
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,14 @@ import {
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
canonicalizeVaryHeader,
|
||||
@@ -55,14 +60,17 @@ function fakeStore(): AuthorizationStore {
|
||||
};
|
||||
}
|
||||
|
||||
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
|
||||
/** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
|
||||
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
||||
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
||||
* suite further down overrides it explicitly to prove the contract itself. */
|
||||
* suite further down overrides them explicitly to prove the contract itself. */
|
||||
function moduleRoute(
|
||||
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
||||
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
||||
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
||||
maxBodyBytes?: number;
|
||||
},
|
||||
): RegisteredModuleHttpRoute {
|
||||
return Object.freeze({ maxBodyBytes: 1024, ...fields });
|
||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||
@@ -110,12 +118,15 @@ function buildModuleApp(options: {
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
||||
moduleRoutes: options.routes,
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||
});
|
||||
@@ -352,6 +363,7 @@ describe("module route handler adaptation", () => {
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
"realtime",
|
||||
"services",
|
||||
"storage",
|
||||
]);
|
||||
@@ -1629,3 +1641,375 @@ describe("module route overlap can never reach mounting", () => {
|
||||
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Explicit, bounded streaming uploads (ADR 0042).
|
||||
*
|
||||
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
|
||||
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
|
||||
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
|
||||
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
|
||||
*/
|
||||
describe("module route streamed body limits", () => {
|
||||
const oneMebibyte = 1024 * 1024;
|
||||
|
||||
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
|
||||
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
|
||||
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
|
||||
let sent = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (sent >= size) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const next = Math.min(chunkSize, size - sent);
|
||||
sent += next;
|
||||
controller.enqueue(new Uint8Array(next).fill(97));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function streamingApp(options: {
|
||||
readonly handler: ModuleRouteHandler;
|
||||
readonly maxBodyBytes: number;
|
||||
readonly evaluator?: AuthorizationEvaluator;
|
||||
}) {
|
||||
return buildModuleApp({
|
||||
...(options.evaluator ? { evaluator: options.evaluator } : {}),
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "streamed",
|
||||
handler: options.handler,
|
||||
maxBodyBytes: options.maxBodyBytes,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/uploads",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
|
||||
* "buffered then handed over". */
|
||||
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
|
||||
return async (request) => {
|
||||
const body = request.body;
|
||||
if (!body) {
|
||||
return Response.json({ bytes: 0 });
|
||||
}
|
||||
const reader = body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
received.bytes += value.byteLength;
|
||||
}
|
||||
return Response.json({ bytes: received.bytes });
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts a body of exactly the declared limit", async () => {
|
||||
const received = { bytes: 0 };
|
||||
const { app } = streamingApp({
|
||||
handler: await countingHandler(received),
|
||||
maxBodyBytes: oneMebibyte,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(oneMebibyte),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ bytes: oneMebibyte });
|
||||
});
|
||||
|
||||
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
|
||||
const received = { bytes: 0 };
|
||||
const counting = await countingHandler(received);
|
||||
const handler = vi.fn<ModuleRouteHandler>(counting);
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(2048, 16),
|
||||
duplex: "half",
|
||||
headers: { "content-length": String(1025), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
|
||||
// byte of the upload reached module code.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(received.bytes).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
|
||||
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
|
||||
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
|
||||
const received = { bytes: 0 };
|
||||
const { app } = streamingApp({
|
||||
handler: await countingHandler(received),
|
||||
maxBodyBytes: 64,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096, 32),
|
||||
duplex: "half",
|
||||
headers: { "content-length": "8", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
// It stopped promptly rather than draining the whole upload first.
|
||||
expect(received.bytes).toBeLessThanOrEqual(96);
|
||||
});
|
||||
|
||||
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
|
||||
// A limit a handler can swallow is not a limit.
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
try {
|
||||
await request.arrayBuffer();
|
||||
} catch {
|
||||
// Deliberately ignored, exactly as a careless module might.
|
||||
}
|
||||
return Response.json({ ok: "handler won" });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096, 32),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
|
||||
let released: (() => void) | undefined;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
released = resolve;
|
||||
});
|
||||
let firstChunkSeen = false;
|
||||
let secondChunkQueued = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (!secondChunkQueued) {
|
||||
secondChunkQueued = true;
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
return;
|
||||
}
|
||||
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
|
||||
// buffering platform would deadlock here rather than reach the assertion below.
|
||||
await finished;
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
|
||||
const first = await reader.read();
|
||||
firstChunkSeen = !first.done;
|
||||
released?.();
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(firstChunkSeen).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("releases an aborted upload without taking the platform down", async () => {
|
||||
const aborted = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
controller.error(new Error("client went away"));
|
||||
},
|
||||
});
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
await request.arrayBuffer();
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: aborted,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||
});
|
||||
|
||||
it("releases a body the handler never read", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
},
|
||||
});
|
||||
const { app } = streamingApp({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
maxBodyBytes: oneMebibyte,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "streamed",
|
||||
handler,
|
||||
maxBodyBytes: 64,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/uploads",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: "a".repeat(65),
|
||||
headers: { "content-length": "65", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(declaredOversize.status).toBe(413);
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
|
||||
// A within-limit body from a denied caller is refused by authorization, and the handler — the
|
||||
// only thing that ever reads a byte in streamed mode — never runs.
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(32, 8),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(response.status).toBe(403);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rejects an untrusted Origin before any body handling", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096),
|
||||
duplex: "half",
|
||||
headers: { origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
|
||||
const { app } = streamingApp({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
maxBodyBytes: 64,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: "secret-payload-".repeat(64),
|
||||
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
|
||||
expect(body).not.toContain("secret-payload");
|
||||
expect(body).not.toContain("64");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("still materializes a buffered body before the handler runs", async () => {
|
||||
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
|
||||
// body is already there and already within its declared allowance.
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
const text = await request.text();
|
||||
return Response.json({ length: text.length });
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "buffered",
|
||||
handler,
|
||||
maxBodyBytes: 128,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: chunkedBody(128, 16),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(withinLimit.status).toBe(200);
|
||||
expect(await withinLimit.json()).toEqual({ length: 128 });
|
||||
|
||||
const overLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: chunkedBody(129, 16),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(overLimit.status).toBe(413);
|
||||
// A buffered route's handler never sees an over-limit request at all.
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
discoverModules,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
@@ -32,6 +33,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||
@@ -76,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -170,6 +173,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
moduleLifecycle: {
|
||||
@@ -181,6 +186,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRealtimePublisher,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
type AuthorizationEvaluator,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type ModuleRealtimeRegistry,
|
||||
type ModuleRealtimeSubscription,
|
||||
startDiscoveredModuleRuntime,
|
||||
type StartedModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { type ServerType, serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mountModuleRealtimeRoutes } from "../src/module-realtime-routes.js";
|
||||
|
||||
const allowedUserId = parseEkklesiaUserId("99999999-9999-4999-8999-999999999999");
|
||||
const deniedUserId = parseEkklesiaUserId("77777777-7777-4777-8777-777777777777");
|
||||
const stableDeliveryUrl =
|
||||
"https://objects.example.test/announcements/slides/welcome.png?X-Amz-Signature=stable";
|
||||
const TRANSPORT_DEADLINE_MS = 2_000;
|
||||
|
||||
async function withinTransportDeadline<Value>(
|
||||
operation: Promise<Value>,
|
||||
message: string,
|
||||
onTimeout: () => void = () => undefined,
|
||||
): Promise<Value> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
onTimeout();
|
||||
reject(new Error(message));
|
||||
}, TRANSPORT_DEADLINE_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTransportCondition(predicate: () => boolean, message: string): Promise<void> {
|
||||
let cancelled = false;
|
||||
await withinTransportDeadline(
|
||||
new Promise<void>((resolve) => {
|
||||
const inspect = (): void => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (predicate()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(inspect, 5);
|
||||
};
|
||||
inspect();
|
||||
}),
|
||||
message,
|
||||
() => {
|
||||
cancelled = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface FixtureControl {
|
||||
readonly events: string[];
|
||||
publisher?: ModuleRealtimePublisher;
|
||||
}
|
||||
|
||||
interface LiveEvent {
|
||||
readonly event: "snapshot" | "update";
|
||||
readonly payload: unknown;
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
class LiveSseConnection {
|
||||
readonly #abort: AbortController;
|
||||
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
#buffer = "";
|
||||
|
||||
constructor(response: Response, abort: AbortController) {
|
||||
if (!response.body) {
|
||||
throw new Error("Realtime integration response had no body.");
|
||||
}
|
||||
this.#abort = abort;
|
||||
this.#reader = response.body.getReader();
|
||||
}
|
||||
|
||||
async readEvent(): Promise<LiveEvent> {
|
||||
for (;;) {
|
||||
const boundary = this.#buffer.indexOf("\n\n");
|
||||
if (boundary >= 0) {
|
||||
const block = this.#buffer.slice(0, boundary);
|
||||
this.#buffer = this.#buffer.slice(boundary + 2);
|
||||
if (block.startsWith(":")) {
|
||||
continue;
|
||||
}
|
||||
const event = block
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("event: "))
|
||||
?.slice("event: ".length);
|
||||
const data = block
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice("data: ".length);
|
||||
if ((event !== "snapshot" && event !== "update") || data === undefined) {
|
||||
throw new Error("Realtime integration received an invalid event.");
|
||||
}
|
||||
const envelope = JSON.parse(data) as { payload?: unknown; sequence?: unknown };
|
||||
if (!Number.isSafeInteger(envelope.sequence) || (envelope.sequence as number) <= 0) {
|
||||
throw new Error("Realtime integration received an invalid sequence.");
|
||||
}
|
||||
return { event, payload: envelope.payload, sequence: envelope.sequence as number };
|
||||
}
|
||||
|
||||
const result = await this.#readWithinDeadline();
|
||||
if (result.done) {
|
||||
throw new Error("Realtime integration stream closed before the expected event.");
|
||||
}
|
||||
this.#buffer += new TextDecoder().decode(result.value, { stream: true });
|
||||
}
|
||||
}
|
||||
|
||||
async expectClosed(): Promise<void> {
|
||||
const result = await this.#readWithinDeadline();
|
||||
expect(result.done).toBe(true);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#abort.abort();
|
||||
await withinTransportDeadline(
|
||||
// Abort and cancel race by design; a rejection means one of them already released the
|
||||
// transport. A deadline still propagates and fails the test.
|
||||
this.#reader.cancel().catch(() => undefined),
|
||||
"Realtime integration client cancellation timed out.",
|
||||
);
|
||||
}
|
||||
|
||||
async #readWithinDeadline() {
|
||||
return withinTransportDeadline(
|
||||
this.#reader.read(),
|
||||
"Realtime integration read timed out.",
|
||||
() => this.#abort.abort(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
readonly directory: string;
|
||||
readonly symbol: symbol;
|
||||
}> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-native-realtime-"));
|
||||
const moduleDirectory = path.join(directory, "announcements");
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
const symbol = Symbol.for(`ekklesia.native-realtime.${Date.now()}.${Math.random()}`);
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = control;
|
||||
const expression = `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`;
|
||||
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: "@test/module-announcements",
|
||||
type: "module",
|
||||
version: "0.1.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: "announcements",
|
||||
manifestVersion: 7,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: "announcements.slideshow.view",
|
||||
},
|
||||
],
|
||||
version: "0.1.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
register({ realtime }) {
|
||||
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||
${expression}.events.push("register");
|
||||
},
|
||||
start({ realtime }) {
|
||||
${expression}.publisher = realtime;
|
||||
realtime.publish("state", {
|
||||
mode: "on",
|
||||
currentSlide: 0,
|
||||
previousSlide: -1,
|
||||
slides: [${JSON.stringify(stableDeliveryUrl)}]
|
||||
});
|
||||
${expression}.events.push("start");
|
||||
},
|
||||
stop() { ${expression}.events.push("stop"); }
|
||||
};\n`,
|
||||
),
|
||||
]);
|
||||
return { directory, symbol };
|
||||
}
|
||||
|
||||
function services(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
function identityRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: async (headers) => {
|
||||
const cookie = headers.get("cookie");
|
||||
if (cookie === "ekklesia-session=allowed") {
|
||||
return createAuthenticatedPrincipal(allowedUserId);
|
||||
}
|
||||
if (cookie === "ekklesia-session=denied") {
|
||||
return createAuthenticatedPrincipal(deniedUserId);
|
||||
}
|
||||
return ANONYMOUS_PRINCIPAL;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(): AuthorizationEvaluator {
|
||||
return createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async ({ userId }) => userId === allowedUserId },
|
||||
platformOwners: { isPlatformOwner: async () => false },
|
||||
});
|
||||
}
|
||||
|
||||
async function bindLiveServer(
|
||||
runtime: StartedModuleRuntime,
|
||||
registry: PermissionRegistry,
|
||||
): Promise<{
|
||||
readonly activeSubscriptions: () => number;
|
||||
readonly origin: string;
|
||||
readonly server: ServerType;
|
||||
}> {
|
||||
const app = new Hono();
|
||||
let activeSubscriptions = 0;
|
||||
const observedRealtime: ModuleRealtimeRegistry = {
|
||||
channels: runtime.realtime.channels,
|
||||
closeSubscriptions: () => runtime.realtime.closeSubscriptions(),
|
||||
publisherFor: (moduleId) => runtime.realtime.publisherFor(moduleId),
|
||||
subscribe: (moduleId, channel): ModuleRealtimeSubscription => {
|
||||
const subscription = runtime.realtime.subscribe(moduleId, channel);
|
||||
activeSubscriptions += 1;
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
close: (): void => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
activeSubscriptions -= 1;
|
||||
}
|
||||
subscription.close();
|
||||
},
|
||||
read: () => subscription.read(),
|
||||
snapshot: subscription.snapshot,
|
||||
});
|
||||
},
|
||||
};
|
||||
mountModuleRealtimeRoutes(app, {
|
||||
evaluator: authorization(),
|
||||
realtime: observedRealtime,
|
||||
registry,
|
||||
runtime: identityRuntime(),
|
||||
});
|
||||
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
|
||||
|
||||
let pendingServer: ServerType | undefined;
|
||||
const server = await withinTransportDeadline(
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
pendingServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, () =>
|
||||
resolve(pendingServer as ServerType),
|
||||
);
|
||||
pendingServer.once("error", reject);
|
||||
}),
|
||||
"Realtime integration server bind timed out.",
|
||||
() => pendingServer?.close(),
|
||||
);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Realtime integration server did not bind a TCP port.");
|
||||
}
|
||||
return {
|
||||
activeSubscriptions: () => activeSubscriptions,
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
server,
|
||||
};
|
||||
}
|
||||
|
||||
async function connect(
|
||||
origin: string,
|
||||
cookie: string,
|
||||
): Promise<{
|
||||
readonly connection: LiveSseConnection;
|
||||
readonly response: Response;
|
||||
}> {
|
||||
const abort = new AbortController();
|
||||
const response = await withinTransportDeadline(
|
||||
fetch(`${origin}/api/v1/module-realtime/announcements/state`, {
|
||||
headers: { cookie },
|
||||
signal: abort.signal,
|
||||
}),
|
||||
"Realtime integration connection timed out.",
|
||||
() => abort.abort(),
|
||||
);
|
||||
return { connection: new LiveSseConnection(response, abort), response };
|
||||
}
|
||||
|
||||
async function closeServer(server: ServerType): Promise<void> {
|
||||
await withinTransportDeadline(
|
||||
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
"Realtime integration server close timed out.",
|
||||
() =>
|
||||
(
|
||||
server as ServerType & {
|
||||
closeAllConnections?: () => void;
|
||||
}
|
||||
).closeAllConnections?.(),
|
||||
);
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ readonly body: unknown; readonly response: Response }> {
|
||||
const abort = new AbortController();
|
||||
const operation = (async (): Promise<{ readonly body: unknown; readonly response: Response }> => {
|
||||
const response = await fetch(url, { ...init, signal: abort.signal });
|
||||
const body = (await response.json()) as unknown;
|
||||
return { body, response };
|
||||
})();
|
||||
return withinTransportDeadline(operation, "Realtime integration finite request timed out.", () =>
|
||||
abort.abort(),
|
||||
);
|
||||
}
|
||||
|
||||
describe("native realtime over a live Node HTTP server", () => {
|
||||
it("loads a real module and proves auth, snapshot, fanout, update, reconnect, and shutdown", async () => {
|
||||
const control: FixtureControl = { events: [] };
|
||||
const fixture = await createFixtureModule(control);
|
||||
let runtime: StartedModuleRuntime | undefined;
|
||||
let server: ServerType | undefined;
|
||||
const connections: LiveSseConnection[] = [];
|
||||
const failures: unknown[] = [];
|
||||
try {
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: fixture.directory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
runtime = await startDiscoveredModuleRuntime(modules, services());
|
||||
expect(control.events).toEqual(["register", "start"]);
|
||||
const live = await bindLiveServer(runtime, composePlatformPermissionRegistry(modules));
|
||||
server = live.server;
|
||||
|
||||
const anonymous = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
);
|
||||
expect(anonymous.response.status).toBe(403);
|
||||
expect(anonymous.body).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
const missingPermission = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
{ headers: { cookie: "ekklesia-session=denied" } },
|
||||
);
|
||||
expect(missingPermission.response.status).toBe(403);
|
||||
|
||||
const first = await connect(live.origin, "ekklesia-session=allowed");
|
||||
const second = await connect(live.origin, "ekklesia-session=allowed");
|
||||
connections.push(first.connection, second.connection);
|
||||
expect(first.response.status).toBe(200);
|
||||
expect(first.response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
|
||||
const expectedInitial = {
|
||||
currentSlide: 0,
|
||||
mode: "on",
|
||||
previousSlide: -1,
|
||||
slides: [stableDeliveryUrl],
|
||||
};
|
||||
expect(await first.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: expectedInitial,
|
||||
sequence: 1,
|
||||
});
|
||||
expect(await second.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: expectedInitial,
|
||||
sequence: 1,
|
||||
});
|
||||
expect(live.activeSubscriptions()).toBe(2);
|
||||
|
||||
control.publisher?.publish("state", {
|
||||
currentSlide: 1,
|
||||
mode: "on",
|
||||
previousSlide: 0,
|
||||
slides: [stableDeliveryUrl],
|
||||
});
|
||||
const expectedUpdate = {
|
||||
event: "update",
|
||||
payload: {
|
||||
currentSlide: 1,
|
||||
mode: "on",
|
||||
previousSlide: 0,
|
||||
slides: [stableDeliveryUrl],
|
||||
},
|
||||
sequence: 2,
|
||||
};
|
||||
expect(await first.connection.readEvent()).toEqual(expectedUpdate);
|
||||
expect(await second.connection.readEvent()).toEqual(expectedUpdate);
|
||||
|
||||
await first.connection.close();
|
||||
await waitForTransportCondition(
|
||||
() => live.activeSubscriptions() === 1,
|
||||
"Realtime integration subscriber cleanup timed out.",
|
||||
);
|
||||
control.publisher?.publish("state", {
|
||||
currentSlide: 2,
|
||||
mode: "off",
|
||||
previousSlide: 1,
|
||||
slides: [stableDeliveryUrl],
|
||||
});
|
||||
expect(await second.connection.readEvent()).toMatchObject({ event: "update", sequence: 3 });
|
||||
|
||||
const reconnected = await connect(live.origin, "ekklesia-session=allowed");
|
||||
connections.push(reconnected.connection);
|
||||
expect(await reconnected.connection.readEvent()).toEqual({
|
||||
event: "snapshot",
|
||||
payload: {
|
||||
currentSlide: 2,
|
||||
mode: "off",
|
||||
previousSlide: 1,
|
||||
slides: [stableDeliveryUrl],
|
||||
},
|
||||
sequence: 3,
|
||||
});
|
||||
expect(live.activeSubscriptions()).toBe(2);
|
||||
|
||||
const secondClosed = second.connection.expectClosed();
|
||||
const reconnectedClosed = reconnected.connection.expectClosed();
|
||||
runtime.realtime.closeSubscriptions();
|
||||
await Promise.all([secondClosed, reconnectedClosed]);
|
||||
await waitForTransportCondition(
|
||||
() => live.activeSubscriptions() === 0,
|
||||
"Realtime integration shutdown cleanup timed out.",
|
||||
);
|
||||
// Stream shutdown happens before ordinary HTTP drain in production. A publisher must remain
|
||||
// usable until that drain completes so an already-running command can publish its result.
|
||||
expect(() => control.publisher?.publish("state", null)).not.toThrow();
|
||||
|
||||
const afterShutdown = await requestJson(
|
||||
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||
{ headers: { cookie: "ekklesia-session=allowed" } },
|
||||
);
|
||||
expect(afterShutdown.response.status).toBe(503);
|
||||
expect(afterShutdown.body).toEqual({ code: "REALTIME_UNAVAILABLE" });
|
||||
await runtime.stop();
|
||||
expect(() => control.publisher?.publish("state", null)).toThrowError(
|
||||
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
} finally {
|
||||
try {
|
||||
runtime?.realtime.closeSubscriptions();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
const connectionCleanup = await Promise.allSettled(
|
||||
connections.map((connection) => connection.close()),
|
||||
);
|
||||
for (const result of connectionCleanup) {
|
||||
if (result.status === "rejected") {
|
||||
failures.push(result.reason);
|
||||
}
|
||||
}
|
||||
if (server) {
|
||||
try {
|
||||
await closeServer(server);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (runtime) {
|
||||
try {
|
||||
await runtime.stop();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
delete (globalThis as unknown as Record<PropertyKey, unknown>)[fixture.symbol];
|
||||
try {
|
||||
await rm(fixture.directory, { force: true, recursive: true });
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw failures[0];
|
||||
}
|
||||
expect(control.events).toEqual(["register", "start", "stop"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
ModuleRealtimeError,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { createPermissionRegistry, type PermissionRegistry } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { type ModuleRealtimeRegistry, StagedModuleRealtimeRegistry } from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MODULE_REALTIME_KEEPALIVE_MS,
|
||||
MODULE_REALTIME_MAX_CONNECTION_AGE_MS,
|
||||
ModuleRealtimeConfigurationError,
|
||||
moduleRealtimeBaseUrl,
|
||||
mountModuleRealtimeRoutes,
|
||||
} from "../src/module-realtime-routes.js";
|
||||
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
const userId = parseEkklesiaUserId("88888888-8888-4888-8888-888888888888");
|
||||
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||
|
||||
function permissionRegistry(): PermissionRegistry {
|
||||
return createPermissionRegistry([
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: permission,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function realtimeFixture(initialPayload: unknown = { mode: "on" }): {
|
||||
readonly publisher: ReturnType<StagedModuleRealtimeRegistry["publisherFor"]>;
|
||||
readonly realtime: ModuleRealtimeRegistry;
|
||||
} {
|
||||
const staged = new StagedModuleRealtimeRegistry();
|
||||
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||
registration.realtime.channel({ channel: "state", permission });
|
||||
registration.commit();
|
||||
const publisher = staged.publisherFor(moduleId);
|
||||
publisher.publish("state", initialPayload as never);
|
||||
return { publisher, realtime: staged.publish() };
|
||||
}
|
||||
|
||||
function appFor(
|
||||
realtime: ModuleRealtimeRegistry,
|
||||
options: {
|
||||
readonly allowed?: boolean;
|
||||
readonly keepaliveMs?: number;
|
||||
readonly maxConnectionAgeMs?: number;
|
||||
readonly signedIn?: boolean;
|
||||
} = {},
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
options.signedIn === false ? ANONYMOUS_PRINCIPAL : createAuthenticatedPrincipal(userId),
|
||||
),
|
||||
};
|
||||
mountModuleRealtimeRoutes(app, {
|
||||
evaluator: {
|
||||
authorize: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: options.signedIn !== false && options.allowed !== false }),
|
||||
},
|
||||
...(options.keepaliveMs === undefined ? {} : { keepaliveMs: options.keepaliveMs }),
|
||||
...(options.maxConnectionAgeMs === undefined
|
||||
? {}
|
||||
: { maxConnectionAgeMs: options.maxConnectionAgeMs }),
|
||||
realtime,
|
||||
registry: permissionRegistry(),
|
||||
runtime,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Realtime test read timed out.")), 1_000);
|
||||
}),
|
||||
]);
|
||||
return result.done ? "" : new TextDecoder().decode(result.value);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
describe("module realtime HTTP transport", () => {
|
||||
it("builds one server-owned root and rejects an unenforceable channel snapshot", () => {
|
||||
expect(moduleRealtimeBaseUrl("announcements")).toBe("/api/v1/module-realtime/announcements");
|
||||
expect(MODULE_REALTIME_KEEPALIVE_MS).toBe(15_000);
|
||||
expect(MODULE_REALTIME_MAX_CONNECTION_AGE_MS).toBe(300_000);
|
||||
|
||||
const { realtime } = realtimeFixture();
|
||||
expect(() =>
|
||||
mountModuleRealtimeRoutes(new Hono(), {
|
||||
evaluator: { authorize: vi.fn() },
|
||||
realtime,
|
||||
registry: createPermissionRegistry([]),
|
||||
runtime: { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn() },
|
||||
}),
|
||||
).toThrow(ModuleRealtimeConfigurationError);
|
||||
});
|
||||
|
||||
it("denies anonymous and unauthorized callers before allocating a subscription", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe,
|
||||
};
|
||||
|
||||
const anonymous = await appFor(observed, { signedIn: false }).request(endpoint);
|
||||
expect(anonymous.status).toBe(403);
|
||||
expect(await anonymous.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
|
||||
const unauthorized = await appFor(observed, { allowed: false }).request(endpoint);
|
||||
expect(unauthorized.status).toBe(403);
|
||||
expect(await unauthorized.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses Hono's implicit HEAD handling without allocating a stream subscription", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe,
|
||||
};
|
||||
|
||||
const response = await appFor(observed).request(endpoint, { method: "HEAD" });
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens an authorized snapshot-first stream with proxy-safe headers and unchanged URLs", async () => {
|
||||
const deliveryUrl =
|
||||
"https://objects.example.test/slides/welcome.png?X-Amz-Signature=byte-identical";
|
||||
const { realtime } = realtimeFixture({
|
||||
currentSlide: 0,
|
||||
mode: "on",
|
||||
previousSlide: -1,
|
||||
slides: [deliveryUrl],
|
||||
});
|
||||
const response = await appFor(realtime).request(endpoint);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("x-accel-buffering")).toBe("no");
|
||||
expect(response.headers.get("vary")).toBe("Origin, Cookie");
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
expect(reader).toBeDefined();
|
||||
const snapshot = await readChunk(reader as ReadableStreamDefaultReader<Uint8Array>);
|
||||
expect(snapshot).toContain("event: snapshot\n");
|
||||
expect(snapshot).toContain('"sequence":1');
|
||||
expect(snapshot).toContain(deliveryUrl);
|
||||
expect(snapshot).not.toContain("\nid:");
|
||||
await reader?.cancel();
|
||||
});
|
||||
|
||||
it("streams ordered updates, emits transport-only keepalives, and closes on shutdown", async () => {
|
||||
const { publisher, realtime } = realtimeFixture({ currentSlide: 0 });
|
||||
const response = await appFor(realtime, { keepaliveMs: 5 }).request(endpoint);
|
||||
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||
publisher.publish("state", { currentSlide: 1 });
|
||||
const update = await readChunk(reader);
|
||||
expect(update).toContain("event: update");
|
||||
expect(update).toContain('"sequence":2');
|
||||
expect(update).toContain('"currentSlide":1');
|
||||
expect(await readChunk(reader)).toBe(": keepalive\n\n");
|
||||
|
||||
const closed = reader.read();
|
||||
realtime.closeSubscriptions();
|
||||
await expect(closed).resolves.toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it("closes a stream at the host-owned maximum age so reconnect reauthorizes", async () => {
|
||||
const { realtime } = realtimeFixture({ currentSlide: 0 });
|
||||
let activeSubscriptions = 0;
|
||||
const observed: ModuleRealtimeRegistry = {
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe: (owner, channel) => {
|
||||
const subscription = realtime.subscribe(owner, channel);
|
||||
activeSubscriptions += 1;
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
close: () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
activeSubscriptions -= 1;
|
||||
}
|
||||
subscription.close();
|
||||
},
|
||||
read: () => subscription.read(),
|
||||
snapshot: subscription.snapshot,
|
||||
});
|
||||
},
|
||||
};
|
||||
const response = await appFor(observed, {
|
||||
keepaliveMs: 1_000,
|
||||
maxConnectionAgeMs: 25,
|
||||
}).request(endpoint);
|
||||
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||
expect(activeSubscriptions).toBe(1);
|
||||
expect(await readChunk(reader)).toBe("");
|
||||
expect(activeSubscriptions).toBe(0);
|
||||
const replacement = realtime.subscribe(moduleId, "state");
|
||||
replacement.close();
|
||||
});
|
||||
|
||||
it("does not expose unknown module/channel combinations", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const app = appFor(realtime);
|
||||
expect((await app.request("/api/v1/module-realtime/calendar/state")).status).toBe(404);
|
||||
expect((await app.request("/api/v1/module-realtime/announcements/other")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("maps subscriber-capacity and unexpected allocation failures to fixed non-secret responses", async () => {
|
||||
const { realtime } = realtimeFixture();
|
||||
const observed = (failure: unknown): ModuleRealtimeRegistry => ({
|
||||
channels: realtime.channels,
|
||||
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||
subscribe: () => {
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
|
||||
const atCapacity = await appFor(
|
||||
observed(new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT")),
|
||||
).request(endpoint);
|
||||
expect(atCapacity.status).toBe(503);
|
||||
expect(await atCapacity.json()).toEqual({ code: "REALTIME_SUBSCRIBER_LIMIT" });
|
||||
|
||||
const detail = "secret-token-and-machine-path";
|
||||
const unavailable = await appFor(observed(new Error(detail))).request(endpoint);
|
||||
expect(unavailable.status).toBe(503);
|
||||
const body = await unavailable.text();
|
||||
expect(body).toBe('{"code":"REALTIME_UNAVAILABLE"}');
|
||||
expect(body).not.toContain(detail);
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,15 @@ import {
|
||||
discoverModules,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import {
|
||||
loadModuleUi,
|
||||
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||
} from "@ekklesia/module-ui-host";
|
||||
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
|
||||
import { Window } from "happy-dom";
|
||||
import {
|
||||
bootstrapDisplayHost,
|
||||
bootstrapHost,
|
||||
createModuleUiContext,
|
||||
type EkklesiaHostConfig,
|
||||
@@ -29,7 +36,10 @@ import {
|
||||
} from "@ekklesia/shell";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
@@ -84,10 +94,14 @@ async function createFixtureModule(
|
||||
modulesDirectory: string,
|
||||
id = "example",
|
||||
name = "Example module",
|
||||
withDisplay = false,
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
if (withDisplay) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
@@ -102,9 +116,20 @@ async function createFixtureModule(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
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",
|
||||
...(withDisplay
|
||||
? {
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: `${id}.display.view`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
@@ -112,6 +137,15 @@ async function createFixtureModule(
|
||||
description: "Read example records.",
|
||||
id: `${id}.records.read`,
|
||||
},
|
||||
...(withDisplay
|
||||
? [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the example display.",
|
||||
id: `${id}.display.view`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
version: "0.1.0",
|
||||
}),
|
||||
@@ -139,6 +173,7 @@ async function createFixtureModule(
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
@@ -154,9 +189,27 @@ async function createFixtureModule(
|
||||
},
|
||||
};\n`,
|
||||
);
|
||||
if (withDisplay) {
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/display/index.js"),
|
||||
`export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
const response = await context.api.request("records");
|
||||
const records = response.ok ? await response.json() : [];
|
||||
container.textContent =
|
||||
context.moduleId + ":display:" + response.status + ":" + records.length;
|
||||
return () => { container.textContent = ""; };
|
||||
},
|
||||
};\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface HarnessOptions {
|
||||
readonly display?: boolean;
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly grants?: readonly string[];
|
||||
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
|
||||
@@ -178,7 +231,7 @@ interface Harness {
|
||||
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await createFixtureModule(modulesDirectory);
|
||||
await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
|
||||
for (const extra of options.extraModules ?? []) {
|
||||
await createFixtureModule(modulesDirectory, extra.id, extra.name);
|
||||
}
|
||||
@@ -214,11 +267,22 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
|
||||
const moduleUi = createModuleUiPublicationRegistry(modules);
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleRealtime: moduleRuntime.realtime,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleUi,
|
||||
...(moduleUi.listDisplays().length === 0
|
||||
? {}
|
||||
: {
|
||||
displayHostAssets: createDisplayHostAssets(
|
||||
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||
"export const displayHost = true;\n",
|
||||
"export const displayWorker = true;\n",
|
||||
),
|
||||
}),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
@@ -226,6 +290,8 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
});
|
||||
@@ -257,10 +323,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
|
||||
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
/** The loader hands the container straight to the module and never inspects it, so a plain sink is
|
||||
* enough and no DOM implementation is needed in this Node-environment suite. */
|
||||
function containerSink(): { textContent: string } {
|
||||
return { textContent: "" };
|
||||
/**
|
||||
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
|
||||
* test environment.
|
||||
*
|
||||
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
|
||||
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
|
||||
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
|
||||
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
|
||||
* dynamically imports real module server entrypoints from temporary directories, which then fail to
|
||||
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
|
||||
* this server-side suite depends on exactly as Node provides it.
|
||||
*/
|
||||
function containerSink(): HostContainer {
|
||||
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||
}
|
||||
|
||||
interface HostContainer {
|
||||
readonly children: { readonly length: number };
|
||||
querySelector(selectors: string): HostContainer | null;
|
||||
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||
}
|
||||
|
||||
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||
function mountedText(container: HostContainer): string {
|
||||
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||
return (
|
||||
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||
@@ -287,8 +378,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
// 2. The URLs came from the server, not from anything the browser composed.
|
||||
expect(descriptor).toEqual({
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
contributions: [],
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
uiUrl: "/api/v1/module-ui/example/0.1.0",
|
||||
version: "0.1.0",
|
||||
});
|
||||
@@ -311,13 +404,92 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
});
|
||||
|
||||
// 5. The module rendered, having reached its own backend through the host.
|
||||
expect(container.textContent).toBe("example:200:2");
|
||||
expect(mountedText(container)).toBe("example:200:2");
|
||||
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||
|
||||
// 7. And it tears down cleanly.
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mountedText(container)).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the stable display route, resolves its real descriptor, and mounts the published display bytes", async () => {
|
||||
const harness = await startHarness({
|
||||
display: true,
|
||||
grants: ["example.records.read", "example.display.view"],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
// 1. A device keeps this version-independent host URL. It is a real permission-gated API route,
|
||||
// not a test-only HTML wrapper around the browser functions below.
|
||||
const page = await (harness.config.fetch as typeof fetch)(
|
||||
"https://ekklesia.example.test/display/example",
|
||||
);
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(await page.text()).toContain("/assets/ekklesia-display/app.js");
|
||||
|
||||
// 2. The display surface's own bootstrap resolves principal first, then the singular protected
|
||||
// descriptor. The browser constructs no artifact, API, or realtime URL itself.
|
||||
const bootstrap = await bootstrapDisplayHost(harness.config, "example");
|
||||
expect(bootstrap.status).toBe("ready");
|
||||
if (bootstrap.status !== "ready") {
|
||||
throw new Error("the authenticated display did not resolve");
|
||||
}
|
||||
expect(bootstrap.descriptor).toEqual({
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
contributions: [],
|
||||
displayPageUrl: "/display/example",
|
||||
id: "example",
|
||||
permissions: ["example.records.read", "example.display.view"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
uiUrl: "/api/v1/module-ui/example/0.1.0/display",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
// 3. Fetch the exact role-specific bytes from the real publication route, then genuinely import
|
||||
// those returned bytes. Reading the fixture file directly would not prove publication.
|
||||
const sourceUrl = moduleUiSourceUrl(harness.config, bootstrap.descriptor);
|
||||
expect(sourceUrl).toBe(
|
||||
"https://ekklesia.example.test/api/v1/module-ui/example/0.1.0/display",
|
||||
);
|
||||
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||
|
||||
// 4. The generic loader receives the host-owned browser context, including the canonical
|
||||
// display page, and mounts the display artifact independently of the Dashboard artifact.
|
||||
const context = createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor: bootstrap.descriptor,
|
||||
principal: bootstrap.principal,
|
||||
});
|
||||
expect(Object.keys(context).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
]);
|
||||
const container = containerSink();
|
||||
const mounted = await loadModuleUi({
|
||||
container: container as unknown as LoaderContainer,
|
||||
context,
|
||||
source: {
|
||||
moduleId: bootstrap.descriptor.id,
|
||||
url: artifactUrl,
|
||||
version: bootstrap.descriptor.version,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mountedText(container)).toBe("example:display:200:2");
|
||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||
await mounted.unmount();
|
||||
expect(mountedText(container)).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
@@ -385,7 +557,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
});
|
||||
|
||||
// Hiding a control is a usability improvement: the module simply did not ask.
|
||||
expect(container.textContent).toBe("example:denied");
|
||||
expect(mountedText(container)).toBe("example:denied");
|
||||
expect(harness.moduleApiCalls).toEqual([]);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
@@ -464,7 +636,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
version: groupedEntry.descriptor.version,
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toBe("example:200:2");
|
||||
expect(mountedText(container)).toBe("example:200:2");
|
||||
await mounted.unmount();
|
||||
|
||||
// Now move it somewhere else entirely.
|
||||
@@ -516,7 +688,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
version: movedEntry.descriptor.version,
|
||||
},
|
||||
});
|
||||
expect(movedContainer.textContent).toBe("example:200:2");
|
||||
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||
"GET /api/v1/modules/example/records",
|
||||
]);
|
||||
|
||||
@@ -20,12 +20,16 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import {
|
||||
@@ -57,6 +61,12 @@ interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly baseline: "authenticated" | "public" | "restricted";
|
||||
}>;
|
||||
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||
readonly contributions?: readonly {
|
||||
readonly localName: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
@@ -80,8 +90,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
server: "./dist/server/index.js",
|
||||
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||
},
|
||||
capabilities: fixture.capabilities ?? [],
|
||||
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: `${fixture.id}.${contribution.localName}`,
|
||||
version: "1.0.0",
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
@@ -101,6 +119,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||
);
|
||||
}
|
||||
for (const contribution of fixture.contributions ?? []) {
|
||||
const contributionDirectory = path.join(
|
||||
moduleDirectory,
|
||||
"dist/contributions",
|
||||
contribution.localName,
|
||||
);
|
||||
await mkdir(contributionDirectory, { recursive: true });
|
||||
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};\n");
|
||||
}
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
@@ -173,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: permissionRegistry, store },
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
@@ -182,6 +210,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
@@ -216,16 +246,19 @@ describe("module UI inventory — what it lists", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(modules).toEqual([
|
||||
{
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/example" },
|
||||
display: null,
|
||||
id: "example",
|
||||
permissions: [],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||
ui: { url: "/api/v1/module-ui/example/0.1.0" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds both URLs with the server's own canonical helpers", async () => {
|
||||
it("builds all host URLs with the server's own canonical helpers", async () => {
|
||||
const { app, modules: discovered } = await install({
|
||||
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
|
||||
principal: signedIn(),
|
||||
@@ -240,6 +273,9 @@ describe("module UI inventory — what it lists", () => {
|
||||
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
|
||||
});
|
||||
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
|
||||
expect(modules[0]?.realtime).toEqual({
|
||||
baseUrl: moduleRealtimeBaseUrl(module.manifest.id),
|
||||
});
|
||||
});
|
||||
|
||||
it("omits a module that declares no UI entrypoint", async () => {
|
||||
@@ -308,8 +344,11 @@ describe("module UI inventory — what it lists", () => {
|
||||
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
||||
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"id",
|
||||
"permissions",
|
||||
"realtime",
|
||||
"ui",
|
||||
"version",
|
||||
]);
|
||||
@@ -512,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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,26 +6,31 @@ import {
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||
import { Window } from "happy-dom";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
MODULE_UI_ASSET_PREFIX,
|
||||
MODULE_UI_CACHE_CONTROL,
|
||||
MODULE_UI_CONTENT_TYPE,
|
||||
moduleContributionArtifactUrl,
|
||||
moduleDisplayUiArtifactUrl,
|
||||
moduleUiArtifactUrl,
|
||||
} from "../src/module-ui-routes.js";
|
||||
import {
|
||||
@@ -59,6 +64,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
* sequence whose exact publication and loader-compatibility can be asserted. */
|
||||
function artifactSource(id: string, version: string): string {
|
||||
return `export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: ${JSON.stringify(version)},
|
||||
mount(container, context) {
|
||||
@@ -77,14 +83,26 @@ interface ModuleFixture {
|
||||
/** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only
|
||||
* module, whose manifest then declares no UI entrypoint at all. */
|
||||
readonly ui?: string;
|
||||
/** Written to a distinct artifact and declared as the protected display entrypoint. */
|
||||
readonly display?: string;
|
||||
/** Extra package-relative files written alongside the artifact — a source map, a sibling bundle
|
||||
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
||||
* files that genuinely exist on disk rather than against hypothetical ones. */
|
||||
readonly extraFiles?: Readonly<Record<string, string>>;
|
||||
/** Capabilities this module provides — what another module's contributions target. */
|
||||
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||
/** Browser renderers this module contributes to another module's capability. */
|
||||
readonly contributions?: readonly {
|
||||
readonly localName: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
readonly source: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
const version = fixture.version ?? "0.1.0";
|
||||
const displayPermission = `${fixture.id}.display.view`;
|
||||
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await writeFile(
|
||||
@@ -103,10 +121,36 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
|
||||
...(fixture.display === undefined
|
||||
? {}
|
||||
: {
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: displayPermission,
|
||||
},
|
||||
}),
|
||||
},
|
||||
capabilities: fixture.capabilities ?? [],
|
||||
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: `${fixture.id}.${contribution.localName}`,
|
||||
version: "1.0.0",
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: `View ${fixture.id}'s display`,
|
||||
id: displayPermission,
|
||||
},
|
||||
],
|
||||
version,
|
||||
}),
|
||||
);
|
||||
@@ -118,6 +162,20 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
||||
}
|
||||
for (const contribution of fixture.contributions ?? []) {
|
||||
const contributionDirectory = path.join(
|
||||
moduleDirectory,
|
||||
"dist/contributions",
|
||||
contribution.localName,
|
||||
);
|
||||
await mkdir(contributionDirectory, { recursive: true });
|
||||
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
|
||||
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
|
||||
}
|
||||
if (fixture.display !== undefined) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||
}
|
||||
for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) {
|
||||
const target = path.join(moduleDirectory, relativePath);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
@@ -138,7 +196,10 @@ interface Installation {
|
||||
* artifact path is hand-built, so these tests exercise the actual security boundary rather than a
|
||||
* reconstruction of it.
|
||||
*/
|
||||
async function install(fixtures: readonly ModuleFixture[]): Promise<Installation> {
|
||||
async function install(
|
||||
fixtures: readonly ModuleFixture[],
|
||||
publicationModuleIds?: readonly string[],
|
||||
): Promise<Installation> {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
for (const fixture of fixtures) {
|
||||
await createModule(modulesDirectory, fixture);
|
||||
@@ -148,6 +209,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
const publicationModules =
|
||||
publicationModuleIds === undefined
|
||||
? modules
|
||||
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
||||
|
||||
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
||||
const services: ModuleRuntimeServices = Object.freeze({ database });
|
||||
@@ -171,10 +236,20 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
||||
} as unknown as AuthorizationStore;
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store },
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
...(publicationModules.some((module) => module.entrypoints.display !== undefined)
|
||||
? {
|
||||
displayHostAssets: createDisplayHostAssets(
|
||||
'<!doctype html><div id="display-root"></div>',
|
||||
"export {};\n",
|
||||
"export {};\n",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleUi: createModuleUiPublicationRegistry(publicationModules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
@@ -182,6 +257,8 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
@@ -343,6 +420,155 @@ describe("module UI artifact publication — happy path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("module display UI artifact publication", () => {
|
||||
it("serves the declared display bytes anonymously at the exact role-specific URL", async () => {
|
||||
const source = `${artifactSource("announcements", "1.4.2")}// display\n`;
|
||||
const { app } = await install([{ display: source, id: "announcements", version: "1.4.2" }]);
|
||||
const url = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
|
||||
|
||||
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2/display`);
|
||||
const response = await app.request(url);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(source);
|
||||
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");
|
||||
const etag = response.headers.get("etag") as string;
|
||||
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
|
||||
|
||||
const revalidated = await app.request(url, { headers: { "if-none-match": etag } });
|
||||
expect(revalidated.status).toBe(304);
|
||||
expect(await revalidated.text()).toBe("");
|
||||
expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
||||
expect(revalidated.headers.get("etag")).toBe(etag);
|
||||
});
|
||||
|
||||
it("keeps display-only, primary-only, both, and neither roles strictly separate", async () => {
|
||||
const primary = `${artifactSource("signage", "0.1.0")}// primary\n`;
|
||||
const display = `${artifactSource("signage", "0.1.0")}// display\n`;
|
||||
const { app } = await install([
|
||||
{ display: `${display}// announcements\n`, id: "announcements" },
|
||||
{ id: "calendar", ui: `${primary}// calendar\n` },
|
||||
{ display, id: "signage", ui: primary },
|
||||
{ id: "backend" },
|
||||
]);
|
||||
|
||||
const primaryUrl = (moduleId: string) => moduleUiArtifactUrl({ moduleId, version: "0.1.0" });
|
||||
const displayUrl = (moduleId: string) =>
|
||||
moduleDisplayUiArtifactUrl({ moduleId, version: "0.1.0" });
|
||||
|
||||
expect((await app.request(primaryUrl("announcements"))).status).toBe(404);
|
||||
expect((await app.request(displayUrl("announcements"))).status).toBe(200);
|
||||
expect((await app.request(primaryUrl("calendar"))).status).toBe(200);
|
||||
expect((await app.request(displayUrl("calendar"))).status).toBe(404);
|
||||
expect(await (await app.request(primaryUrl("signage"))).text()).toBe(primary);
|
||||
expect(await (await app.request(displayUrl("signage"))).text()).toBe(display);
|
||||
expect((await app.request(primaryUrl("backend"))).status).toBe(404);
|
||||
expect((await app.request(displayUrl("backend"))).status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses wrong versions with the same sanitized response as an undeclared display", async () => {
|
||||
const { app } = await install([
|
||||
{
|
||||
display: `${artifactSource("announcements", "1.4.2")}// display\n`,
|
||||
id: "announcements",
|
||||
version: "1.4.2",
|
||||
},
|
||||
{ id: "calendar", ui: artifactSource("calendar", "0.1.0") },
|
||||
]);
|
||||
|
||||
const responses = await Promise.all(
|
||||
[
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "latest" }),
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "missing", version: "1.4.2" }),
|
||||
].map(async (url) => {
|
||||
const response = await app.request(url);
|
||||
return { body: await response.text(), status: response.status };
|
||||
}),
|
||||
);
|
||||
|
||||
expect(responses).toEqual([
|
||||
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
||||
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
||||
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never turns the display URL into a package directory or generic role selector", async () => {
|
||||
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
|
||||
const { app } = await install([
|
||||
{
|
||||
display,
|
||||
extraFiles: {
|
||||
".env": "DISPLAY_SECRET=do-not-serve\n",
|
||||
"dist/display/index.js.map": "display source map secret",
|
||||
"dist/display/internal-chunk.js": "export const internal = 'secret';\n",
|
||||
},
|
||||
id: "announcements",
|
||||
},
|
||||
]);
|
||||
const exact = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
||||
|
||||
for (const url of [
|
||||
`${exact}.map`,
|
||||
`${exact}/index.js.map`,
|
||||
`${exact}/internal-chunk.js`,
|
||||
`${exact}/../../.env`,
|
||||
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/dashboard`,
|
||||
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/arbitrary-role`,
|
||||
]) {
|
||||
const response = await app.request(url);
|
||||
expect(response.status, url).toBe(404);
|
||||
const body = await response.text();
|
||||
expect(body, url).not.toContain("secret");
|
||||
expect(body, url).not.toContain("mount");
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot publish a blocked module omitted from Core's eligible registry input", async () => {
|
||||
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
|
||||
const { app } = await install(
|
||||
[
|
||||
{ display, id: "announcements" },
|
||||
{ display: `${display}// signage\n`, id: "signage" },
|
||||
],
|
||||
["signage"],
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
await app.request(
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await app.request(moduleDisplayUiArtifactUrl({ moduleId: "signage", version: "0.1.0" })))
|
||||
.status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it("reports the same fixed 503 when the validated display artifact disappears", async () => {
|
||||
const { app, modulesDirectory } = await install([
|
||||
{
|
||||
display: `${artifactSource("announcements", "0.1.0")}// display\n`,
|
||||
id: "announcements",
|
||||
},
|
||||
]);
|
||||
await rm(path.join(modulesDirectory, "announcements/dist/display/index.js"));
|
||||
|
||||
const response = await app.request(
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module UI artifact publication — module identity", () => {
|
||||
async function expectNotPublished(app: Hono, url: string): Promise<void> {
|
||||
const response = await app.request(url);
|
||||
@@ -672,6 +898,12 @@ describe("module UI artifact URL generation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface HostContainer {
|
||||
readonly children: { readonly length: number };
|
||||
querySelector(selectors: string): HostContainer | null;
|
||||
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||
}
|
||||
|
||||
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
||||
/**
|
||||
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
||||
@@ -690,24 +922,40 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader forwards the container untouched to the module's own `mount` and never inspects it,
|
||||
* so a plain sink is enough and no DOM implementation is required. The container's type is read
|
||||
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
|
||||
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
|
||||
* route exists to keep apart.
|
||||
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
||||
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
||||
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
||||
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
||||
*/
|
||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||
|
||||
function containerSink(): { textContent: string } {
|
||||
return { textContent: "" };
|
||||
/**
|
||||
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
|
||||
* real isolated root inside the container, and installing a DOM environment here would replace
|
||||
* the module runner's resolution for a server-side suite that dynamically imports real module
|
||||
* entrypoints.
|
||||
*/
|
||||
function containerSink(): HostContainer {
|
||||
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||
}
|
||||
|
||||
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||
function mountedText(container: HostContainer): string {
|
||||
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
|
||||
return (
|
||||
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hostContext() {
|
||||
return {
|
||||
api: { request: async () => new Response(null, { status: 200 }) },
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "announcements",
|
||||
permissions: { has: () => false },
|
||||
principal: { kind: "anonymous" as const },
|
||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -724,9 +972,9 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
source: { moduleId: "announcements", url, version: "1.4.2" },
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("announcements:mounted");
|
||||
expect(mountedText(container)).toBe("announcements:mounted");
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(container.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still rejects the published artifact when the host expects a different identity", async () => {
|
||||
@@ -744,3 +992,109 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
|
||||
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
|
||||
*/
|
||||
describe("module contribution artifact publication", () => {
|
||||
const signage = {
|
||||
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||
id: "signage",
|
||||
};
|
||||
const contributionSource = "export default { contributed: true };\n";
|
||||
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange,
|
||||
localName: "signage-slide",
|
||||
source: contributionSource,
|
||||
},
|
||||
],
|
||||
id: "announcements",
|
||||
});
|
||||
const contributionUrl = moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
"announcements.signage-slide",
|
||||
);
|
||||
|
||||
it("serves the declared contribution artifact byte for byte", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(contributionUrl);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
||||
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(await response.text()).toBe(contributionSource);
|
||||
});
|
||||
|
||||
it("builds a URL that cannot select a neighbouring file", () => {
|
||||
expect(contributionUrl).toBe(
|
||||
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||
);
|
||||
// The contribution id is one segment's worth of text, always — a value that needed encoding
|
||||
// simply fails to resolve.
|
||||
expect(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
"../../../etc/passwd",
|
||||
),
|
||||
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown contribution id", "announcements.nope"],
|
||||
["another module's contribution id", "signage.slide-renderer"],
|
||||
["a path traversal", "../../../etc/passwd"],
|
||||
["the artifact's source map", "announcements.signage-slide.map"],
|
||||
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
contributionId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
||||
});
|
||||
|
||||
it("refuses a version the platform does not have installed", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "9.9.9" },
|
||||
"announcements.signage-slide",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("publishes nothing when no installed module provides the target capability", async () => {
|
||||
const { app } = await install([announcements()]);
|
||||
|
||||
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("publishes nothing when the provider is outside the declared compatible range", async () => {
|
||||
const { app } = await install([signage, announcements("^2.0.0")]);
|
||||
|
||||
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("keeps the sibling source map and the module's own directory unreachable", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
|
||||
const response = await app.request(`${contributionUrl}${suffix}`);
|
||||
expect(response.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
type DashboardHostAssets,
|
||||
} from "../../src/dashboard-host-assets.js";
|
||||
|
||||
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
|
||||
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
|
||||
export function testDashboardHostAssets(): DashboardHostAssets {
|
||||
return createDashboardHostAssets(
|
||||
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||
"export const dashboardHost = true;\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ApiHealthOptions } from "../../src/health-routes.js";
|
||||
|
||||
/** Readiness inputs for a composition whose tests do not exercise health: the database answers and
|
||||
* module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */
|
||||
export function readyHealth(): ApiHealthOptions {
|
||||
return {
|
||||
checkDatabase: async () => undefined,
|
||||
isModuleStartupComplete: () => true,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
"./src/dashboard/schema.ts",
|
||||
"./src/modules/lifecycle-schema.ts",
|
||||
"./src/modules/configuration-schema.ts",
|
||||
"./src/devices/schema.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE "ekklesia_core_device" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"credential_hash" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_by_user_id" text,
|
||||
"last_seen_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"revoked_by_user_id" text,
|
||||
CONSTRAINT "ekklesia_core_device_name_check" CHECK (length("ekklesia_core_device"."name") between 1 and 128),
|
||||
CONSTRAINT "ekklesia_core_device_credential_hash_check" CHECK (length("ekklesia_core_device"."credential_hash") = 64)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ekklesia_core_device_pairing_request" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"claim_secret_hash" text NOT NULL,
|
||||
"module_id" text NOT NULL,
|
||||
"permission_id" text NOT NULL,
|
||||
"state" text DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"device_name" text,
|
||||
"approved_at" timestamp with time zone,
|
||||
"approved_by_user_id" text,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"device_id" uuid,
|
||||
CONSTRAINT "ekklesia_core_device_pairing_code_check" CHECK ("ekklesia_core_device_pairing_request"."code" ~ '^[0-9]{6}$'),
|
||||
CONSTRAINT "ekklesia_core_device_pairing_claim_hash_check" CHECK (length("ekklesia_core_device_pairing_request"."claim_secret_hash") = 64),
|
||||
CONSTRAINT "ekklesia_core_device_pairing_state_check" CHECK ("ekklesia_core_device_pairing_request"."state" in ('pending', 'approved', 'consumed'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ekklesia_core_device_permission" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"device_id" uuid NOT NULL,
|
||||
"permission_id" text NOT NULL,
|
||||
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"granted_by_user_id" text,
|
||||
"revoked_at" timestamp with time zone,
|
||||
CONSTRAINT "ekklesia_core_device_permission_id_check" CHECK (length("ekklesia_core_device_permission"."permission_id") between 1 and 128)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("revoked_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("approved_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("granted_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "ekklesia_core_device_credential_hash_idx" ON "ekklesia_core_device" USING btree ("credential_hash");--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_device_revoked_idx" ON "ekklesia_core_device" USING btree ("revoked_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "ekklesia_core_device_pairing_code_idx" ON "ekklesia_core_device_pairing_request" USING btree ("code") WHERE "ekklesia_core_device_pairing_request"."state" <> 'consumed';--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_device_pairing_claim_idx" ON "ekklesia_core_device_pairing_request" USING btree ("claim_secret_hash");--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_device_permission_lookup_idx" ON "ekklesia_core_device_permission" USING btree ("device_id","permission_id");
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,18 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0006_device_identity",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,20 @@
|
||||
"when": 1787451496425,
|
||||
"tag": "0005_module_configuration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1787543773057,
|
||||
"tag": "0006_device_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1787543773058,
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -42,6 +42,18 @@
|
||||
"./database": {
|
||||
"types": "./dist/database/index.d.ts",
|
||||
"import": "./dist/database/index.js"
|
||||
},
|
||||
"./devices": {
|
||||
"types": "./dist/devices/index.d.ts",
|
||||
"import": "./dist/devices/index.js"
|
||||
},
|
||||
"./mail": {
|
||||
"types": "./dist/mail/index.d.ts",
|
||||
"import": "./dist/mail/index.js"
|
||||
},
|
||||
"./host": {
|
||||
"types": "./dist/host/index.d.ts",
|
||||
"import": "./dist/host/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -58,14 +70,18 @@
|
||||
"@ekklesia/contracts": "workspace:*",
|
||||
"better-auth": "1.6.23",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"nodemailer": "9.0.5",
|
||||
"pg": "8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/smtp-server": "3.5.13",
|
||||
"auth": "1.6.23",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"smtp-server": "3.19.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
|
||||
@@ -5,11 +5,16 @@ import {
|
||||
type AuthorizationScope,
|
||||
type EkklesiaPrincipal,
|
||||
isAuthenticatedPrincipal,
|
||||
isDevicePrincipal,
|
||||
permissionBaselineSchema,
|
||||
safeParseAuthorizationScope,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js";
|
||||
import type {
|
||||
AuthorizationDevicePort,
|
||||
AuthorizationGrantPort,
|
||||
AuthorizationPlatformOwnerPort,
|
||||
} from "./ports.js";
|
||||
|
||||
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
|
||||
|
||||
@@ -54,6 +59,10 @@ export interface AuthorizationEvaluator {
|
||||
export interface CreateAuthorizationEvaluatorOptions {
|
||||
readonly grants: AuthorizationGrantPort;
|
||||
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
||||
/** Device authority. Optional so every existing caller composes unchanged; when it is absent a
|
||||
* device principal simply has no permissions and is denied, which is the correct answer for a
|
||||
* deployment that has no device support wired at all. */
|
||||
readonly devices?: AuthorizationDevicePort;
|
||||
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
|
||||
* real wall clock; production composition must leave this unset. */
|
||||
readonly now?: () => Date;
|
||||
@@ -100,17 +109,14 @@ export function createAuthorizationEvaluator(
|
||||
}
|
||||
|
||||
if (parsedBaseline.data === "authenticated") {
|
||||
// A device deliberately does not satisfy this. The baseline means "a person is signed in",
|
||||
// and every route that settles for it — the module UI inventory, anything a human is
|
||||
// expected to be looking at — would otherwise open to an unattended screen (ADR 0036).
|
||||
return isAuthenticatedPrincipal(request.principal)
|
||||
? AUTHORIZATION_ALLOWED
|
||||
: AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
// parsedBaseline.data === "restricted": requires an authenticated principal AND
|
||||
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
|
||||
if (!isAuthenticatedPrincipal(request.principal)) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
let scope: AuthorizationScope | undefined;
|
||||
if (request.scope !== undefined) {
|
||||
const parsedScope = safeParseAuthorizationScope(request.scope);
|
||||
@@ -120,6 +126,34 @@ export function createAuthorizationEvaluator(
|
||||
scope = parsedScope.data;
|
||||
}
|
||||
|
||||
// A device satisfies `restricted` only by holding that exact permission, and by no other
|
||||
// route: there is no owner check here, no baseline that stands in for a grant, and no branch
|
||||
// that returns `allowed` before this lookup. "Paired" is not authority — a device that was
|
||||
// paired for one display has nothing else.
|
||||
if (isDevicePrincipal(request.principal)) {
|
||||
if (!options.devices || scope !== undefined) {
|
||||
// A scoped check has no device meaning yet; denying is the honest answer rather than
|
||||
// silently treating it as unscoped.
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
try {
|
||||
return (await options.devices.hasActivePermission(
|
||||
request.principal.deviceId,
|
||||
parsedPermission.data,
|
||||
))
|
||||
? AUTHORIZATION_ALLOWED
|
||||
: AUTHORIZATION_DENIED;
|
||||
} catch {
|
||||
throw new AuthorizationEvaluationError();
|
||||
}
|
||||
}
|
||||
|
||||
// parsedBaseline.data === "restricted": requires an authenticated principal AND
|
||||
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
|
||||
if (!isAuthenticatedPrincipal(request.principal)) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
const userId = request.principal.userId;
|
||||
let isOwner: boolean;
|
||||
let hasGrant: boolean;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./database.js";
|
||||
export * from "./evaluator.js";
|
||||
export * from "./initial-owner.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./ports.js";
|
||||
export * from "./store.js";
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { BootstrapIdentityCreator } from "../identity/bootstrap-identity.js";
|
||||
import { BootstrapIdentityError } from "../identity/bootstrap-identity.js";
|
||||
import type { IdentityDrizzleDatabase } from "../identity/database.js";
|
||||
import { ekklesiaCoreAccount, ekklesiaCoreUser } from "../identity/schema.js";
|
||||
import { bootstrapPlatformOwner, PlatformOwnerBootstrapError } from "./bootstrap.js";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import { ekklesiaCorePlatformOwnerBootstrap } from "./schema.js";
|
||||
|
||||
export type InitialPlatformOwnerErrorCode =
|
||||
| "INITIAL_OWNER_ALREADY_COMPLETED"
|
||||
| "INITIAL_OWNER_EMAIL_INVALID"
|
||||
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
||||
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
||||
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
|
||||
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
||||
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
||||
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
||||
| "INITIAL_OWNER_SCHEMA_UNAVAILABLE";
|
||||
|
||||
const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, string>> = Object.freeze(
|
||||
{
|
||||
INITIAL_OWNER_ALREADY_COMPLETED:
|
||||
"This installation already has a platform owner. Initial-owner bootstrap runs once and cannot run again.",
|
||||
INITIAL_OWNER_EMAIL_INVALID: "The email address is not a valid address.",
|
||||
INITIAL_OWNER_IDENTITY_NOT_CREATED:
|
||||
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
||||
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.",
|
||||
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:
|
||||
"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_SHORT: "The password is shorter than the identity system allows.",
|
||||
INITIAL_OWNER_SCHEMA_UNAVAILABLE:
|
||||
"The database is unreachable or has not been migrated. Apply migrations before creating the initial owner.",
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Never carries the password, a hash, a connection string, SQL, a driver error, or a cause. The
|
||||
* messages are written to be read by an operator at a terminal, so each one states what happened
|
||||
* *and* what is still true about the installation — in particular whether bootstrap remains
|
||||
* available, which is the only fact that determines what they should do next.
|
||||
*/
|
||||
export class InitialPlatformOwnerError extends Error {
|
||||
readonly code: InitialPlatformOwnerErrorCode;
|
||||
|
||||
constructor(code: InitialPlatformOwnerErrorCode) {
|
||||
super(initialOwnerMessages[code]);
|
||||
this.name = "InitialPlatformOwnerError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
// A CLI-level advisory key distinct from `bootstrap.ts`'s: taking the same one would deadlock this
|
||||
// operation against the grant it performs. This serializes whole invocations; that one serializes
|
||||
// the grant transaction itself, and both remain in force.
|
||||
const initialOwnerLockNamespace = 1_500_000_003;
|
||||
const initialOwnerLockKey = 1_500_000_004;
|
||||
|
||||
export interface CreateInitialPlatformOwnerDependencies {
|
||||
readonly authorizationDb: AuthorizationDrizzleDatabase;
|
||||
readonly identityDb: IdentityDrizzleDatabase;
|
||||
readonly createIdentity: BootstrapIdentityCreator;
|
||||
/** Injected so a partial failure — identity created, ownership not granted — can be exercised.
|
||||
* Production composition leaves it unset and the real single-use bootstrap is used. */
|
||||
readonly grantOwnership?: (userId: EkklesiaUserId) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateInitialPlatformOwnerInput {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
/** The display name the identity model requires. */
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface InitialPlatformOwnerResult {
|
||||
readonly email: string;
|
||||
readonly userId: EkklesiaUserId;
|
||||
/** `created` when this call made the identity; `adopted` when one already existed for that
|
||||
* address and only ownership was granted. Reported so an operator is never told something was
|
||||
* created when it was not. */
|
||||
readonly identity: "adopted" | "created";
|
||||
}
|
||||
|
||||
interface ExistingIdentity {
|
||||
readonly userId: string;
|
||||
readonly hasCredential: boolean;
|
||||
/** As the identity system actually stored it, which is not necessarily as the operator typed it. */
|
||||
readonly storedEmail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an identity by email, case-insensitively.
|
||||
*
|
||||
* Deliberately not an exact match on the caller's spelling: Better Auth lowercases the address it
|
||||
* stores (verified against the pinned release — `[email protected]` persists as
|
||||
* `[email protected]`), so an exact comparison would fail to find the very account this operation
|
||||
* had just created and would report it missing.
|
||||
*/
|
||||
async function findIdentityByEmail(
|
||||
db: IdentityDrizzleDatabase,
|
||||
email: string,
|
||||
): Promise<ExistingIdentity | undefined> {
|
||||
const rows = await db
|
||||
.select({ email: ekklesiaCoreUser.email, id: ekklesiaCoreUser.id })
|
||||
.from(ekklesiaCoreUser)
|
||||
.where(sql`lower(${ekklesiaCoreUser.email}) = ${email.trim().toLowerCase()}`)
|
||||
.limit(1);
|
||||
const found = rows[0];
|
||||
if (!found) {
|
||||
return undefined;
|
||||
}
|
||||
// A row in `user` alone is not an account anyone can sign in as — the credential lives in
|
||||
// `account`. The distinction matters: granting ownership to a passwordless row would consume the
|
||||
// one-time bootstrap on an identity nobody can authenticate as, leaving the installation with an
|
||||
// owner that cannot log in and no second chance.
|
||||
const credentials = await db
|
||||
.select({ id: ekklesiaCoreAccount.id })
|
||||
.from(ekklesiaCoreAccount)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreAccount.userId, found.id),
|
||||
eq(ekklesiaCoreAccount.providerId, "credential"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
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 {
|
||||
if (error instanceof BootstrapIdentityError) {
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_SHORT");
|
||||
}
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_LONG");
|
||||
}
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_EMAIL_INVALID") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||
}
|
||||
}
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the first platform owner of a fresh installation: one identity, then the single-use
|
||||
* ownership grant.
|
||||
*
|
||||
* ## Why the identity is verified rather than trusted
|
||||
*
|
||||
* Better Auth's sign-up returns a **fabricated success** when the address is already registered —
|
||||
* a user object carrying an id that was never persisted, with the database left unchanged (verified
|
||||
* against the pinned release). On a public endpoint that is correct: it refuses to tell an
|
||||
* anonymous caller which addresses exist. Here it is a trap, because a caller that believed the
|
||||
* response would report an owner it never created and then grant ownership to an id that does not
|
||||
* exist. So the identity is always read back from the database, and that read — not the sign-up
|
||||
* response — is what the grant uses.
|
||||
*
|
||||
* ## Why an existing identity is adopted rather than refused or overwritten
|
||||
*
|
||||
* If the address already has a usable account and bootstrap is still open, ownership is granted to
|
||||
* it and **its password is left completely alone**. This is precisely the recovery path for the one
|
||||
* partial failure this operation can have: sign-up commits through Better Auth's own connection and
|
||||
* the grant runs in a separate transaction, so a crash between them leaves an identity with no
|
||||
* ownership. Refusing would strand a fresh installation with no way forward but manual SQL; a
|
||||
* password reset would be a silent takeover of an account the operator may not own. Adoption
|
||||
* changes no credential, and it is reported as `adopted` so nobody is told an account was created
|
||||
* when it was not.
|
||||
*
|
||||
* That is safe here specifically because production has **no** other way to create an identity —
|
||||
* public signup cannot be enabled at all — so an account present on a fresh installation was put
|
||||
* there by someone who already had database access.
|
||||
*
|
||||
* ## Ordering
|
||||
*
|
||||
* The bootstrap sentinel is checked before an identity is created, so a closed bootstrap never
|
||||
* leaves a stray account behind. That check is an early exit, not the authority: `bootstrapPlatformOwner`
|
||||
* re-checks it inside its own locked transaction, which is what actually makes the grant single-use.
|
||||
*/
|
||||
export async function createInitialPlatformOwner(
|
||||
dependencies: CreateInitialPlatformOwnerDependencies,
|
||||
input: CreateInitialPlatformOwnerInput,
|
||||
): Promise<InitialPlatformOwnerResult> {
|
||||
const email = input.email.trim();
|
||||
if (email.length === 0) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||
}
|
||||
|
||||
// Serializes entire invocations against each other. Without it two operators racing on a fresh
|
||||
// installation would both pass the sentinel check and both create an identity, and only then
|
||||
// would one lose the grant — leaving a stray account that can sign in and owns nothing. The grant
|
||||
// alone was already safe; this makes the whole command safe.
|
||||
return dependencies.authorizationDb.transaction(async (lockTx) => {
|
||||
await lockTx.execute(
|
||||
sql`select pg_advisory_xact_lock(${initialOwnerLockNamespace}, ${initialOwnerLockKey})`,
|
||||
);
|
||||
|
||||
let alreadyConsumed: boolean;
|
||||
try {
|
||||
const consumed = await lockTx
|
||||
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||
.limit(1);
|
||||
alreadyConsumed = consumed.length > 0;
|
||||
} catch {
|
||||
// An unreachable database and a database without the authorization tables are the same
|
||||
// situation to an operator running this on a fresh server: the deployment step before this
|
||||
// one has not happened. Never migrated implicitly — see ADR 0035.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||
}
|
||||
if (alreadyConsumed) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||
}
|
||||
|
||||
let existing: ExistingIdentity | undefined;
|
||||
try {
|
||||
existing = await findIdentityByEmail(dependencies.identityDb, email);
|
||||
} catch {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||
}
|
||||
if (existing && !existing.hasCredential) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_UNUSABLE");
|
||||
}
|
||||
|
||||
// Captured before the read-back below, which cannot distinguish the two: an identity this call
|
||||
// just created and one it adopted both come back with a credential.
|
||||
const identity: "adopted" | "created" = existing ? "adopted" : "created";
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
await dependencies.createIdentity({
|
||||
email,
|
||||
name: input.name,
|
||||
password: input.password,
|
||||
});
|
||||
} catch (error) {
|
||||
throw translateIdentityCreationError(error);
|
||||
}
|
||||
existing = await findIdentityByEmail(dependencies.identityDb, email).catch(() => undefined);
|
||||
if (!existing) {
|
||||
// Reached when sign-up reported success without persisting anything.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||
}
|
||||
}
|
||||
|
||||
const parsedUserId = safeParseEkklesiaUserId(existing.userId);
|
||||
if (!parsedUserId.success) {
|
||||
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 {
|
||||
const grant =
|
||||
dependencies.grantOwnership ??
|
||||
((userId: EkklesiaUserId) =>
|
||||
bootstrapPlatformOwner(dependencies.authorizationDb, { userId }));
|
||||
await grant(parsedUserId.data);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PlatformOwnerBootstrapError &&
|
||||
error.code === "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||
) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||
}
|
||||
// The identity is real and remains usable; the sentinel rolled back with the grant, so
|
||||
// re-running this command with the same address adopts it and finishes.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_OWNERSHIP_GRANT_FAILED");
|
||||
}
|
||||
|
||||
// The stored address, never the operator's spelling: the identity system lowercases what it
|
||||
// persists, and telling someone to sign in as `[email protected]` when the account is
|
||||
// `[email protected]` invites them to conclude the command failed.
|
||||
return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data });
|
||||
});
|
||||
}
|
||||
@@ -152,6 +152,23 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
|
||||
"core.modules.configuration.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* Restricted, and deliberately a **platform** permission rather than a module's.
|
||||
*
|
||||
* A device is an identity the platform issues, so deciding that a screen in the foyer may exist —
|
||||
* approving its pairing, seeing what has been paired, and taking it away again — is authority over
|
||||
* Ekklesia itself, not over the module whose display happens to be shown. Letting a module declare
|
||||
* or gate it would mean whoever administers a slideshow could mint credentials that outlive it.
|
||||
*
|
||||
* It gates approval, listing, and revocation together. Splitting "see the paired devices" from
|
||||
* "revoke one" would be ceremony: the same person is trusted with both, and a reader who cannot act
|
||||
* on what they see is not a role anyone has asked for (ADR 0027's reasoning, applied again).
|
||||
*
|
||||
* Holding it confers **no** authority inside any module and no ability to read a device credential
|
||||
* — none is stored in a recoverable form, so there is no code path that could serve one (ADR 0036).
|
||||
*/
|
||||
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
|
||||
|
||||
/**
|
||||
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||
@@ -185,6 +202,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
||||
description: "Read and change an installed module's own configuration values.",
|
||||
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
id: CORE_DEVICES_MANAGE,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||
import type {
|
||||
AuthorizationScope,
|
||||
EkklesiaDeviceId,
|
||||
EkklesiaUserId,
|
||||
PermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
export interface ActiveGrantQuery {
|
||||
readonly userId: EkklesiaUserId;
|
||||
@@ -26,3 +31,17 @@ export interface AuthorizationGrantPort {
|
||||
export interface AuthorizationPlatformOwnerPort {
|
||||
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The evaluator's only read dependency for **device** authority.
|
||||
*
|
||||
* Separate from `AuthorizationGrantPort` because the two answer questions about different kinds of
|
||||
* subject, not because devices are a special case in the decision: `authorize` consults exactly one
|
||||
* of them per call, by principal kind, and neither can be reached without going through it.
|
||||
*
|
||||
* There is deliberately no device counterpart to `AuthorizationPlatformOwnerPort`. A device is
|
||||
* never a platform owner, so there is no port that could accidentally say it was.
|
||||
*/
|
||||
export interface AuthorizationDevicePort {
|
||||
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createAuthorizationDrizzleDatabase,
|
||||
createInitialPlatformOwner,
|
||||
InitialPlatformOwnerError,
|
||||
} from "../authorization/index.js";
|
||||
import {
|
||||
createBootstrapIdentityCreator,
|
||||
createIdentityDrizzleDatabase,
|
||||
} from "../identity/index.js";
|
||||
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||
|
||||
/**
|
||||
* Creates the first platform owner of a fresh installation — one identity, then the single-use
|
||||
* ownership grant — so a freshly deployed Ekklesia has a supported way to get its first
|
||||
* administrator (ADR 0035).
|
||||
*
|
||||
* This exists because the login page has nothing to log in to on a new installation. Public signup
|
||||
* is disabled in every environment and production cannot enable it at all, which is correct and is
|
||||
* not changed here; the consequence was simply that no supported path created the first account.
|
||||
* This is that path, and it is deliberately the same shape as `pnpm authorization:bootstrap-owner`
|
||||
* and `pnpm modules:enable`: explicit, operator-invoked, never HTTP-exposed, never inferred, and
|
||||
* reachable only by someone who already holds the database credentials.
|
||||
*
|
||||
* It creates no HTTP route, starts no server, and applies no migration.
|
||||
*/
|
||||
class InitialOwnerCliError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InitialOwnerCliError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const usage =
|
||||
"Usage: pnpm identity:create-initial-owner -- --email <address> [--name <display name>]\n" +
|
||||
"The password is never taken from the command line. It is prompted for when a terminal is\n" +
|
||||
"attached, and otherwise read from standard input:\n" +
|
||||
" printf '%s' \"$PASSWORD\" | pnpm identity:create-initial-owner -- --email [email protected]";
|
||||
|
||||
interface ParsedArgs {
|
||||
readonly email: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts` and
|
||||
* `set-module-enabled.ts`. There is deliberately no `--password`: see `readPassword`. */
|
||||
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||
let email: string | undefined;
|
||||
let name: string | undefined;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = argv[index];
|
||||
if (flag === "--email") {
|
||||
email = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--name") {
|
||||
name = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--password" || flag?.startsWith("--password=") === true) {
|
||||
// Refused explicitly rather than ignored, so an operator who tries it learns why instead of
|
||||
// wondering why their password was not used.
|
||||
throw new InitialOwnerCliError(
|
||||
"--password is not supported: a command-line password is visible in shell history and in the process list.\n" +
|
||||
usage,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (email === undefined || email.trim().length === 0) {
|
||||
throw new InitialOwnerCliError(usage);
|
||||
}
|
||||
return name === undefined ? { email } : { email, name };
|
||||
}
|
||||
|
||||
/** Reads one line without echoing it. No dependency is added for this: the command needs exactly
|
||||
* one hidden field, and Node's own raw-mode stdin is enough. */
|
||||
function promptHidden(prompt: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const input = process.stdin;
|
||||
process.stdout.write(prompt);
|
||||
input.setRawMode(true);
|
||||
input.resume();
|
||||
input.setEncoding("utf8");
|
||||
let value = "";
|
||||
const finish = (settle: () => void): void => {
|
||||
input.removeListener("data", onData);
|
||||
input.setRawMode(false);
|
||||
input.pause();
|
||||
process.stdout.write("\n");
|
||||
settle();
|
||||
};
|
||||
const onData = (chunk: string): void => {
|
||||
for (const character of chunk) {
|
||||
if (character === "\r" || character === "\n" || character === "\u0004") {
|
||||
finish(() => resolve(value));
|
||||
return;
|
||||
}
|
||||
if (character === "\u0003") {
|
||||
finish(() => reject(new InitialOwnerCliError("Cancelled. No changes were made.")));
|
||||
return;
|
||||
}
|
||||
// Backspace and delete, so a mistyped character can be corrected in a field nobody can see.
|
||||
if (character === "\u007f" || character === "\b") {
|
||||
value = value.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
value += character;
|
||||
}
|
||||
};
|
||||
input.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads standard input to end, for automated deployment. */
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let buffer = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => resolve(buffer));
|
||||
process.stdin.on("error", () =>
|
||||
reject(new InitialOwnerCliError("Standard input could not be read.")),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains the password without it ever appearing in `argv`, an environment variable, or the
|
||||
* terminal.
|
||||
*
|
||||
* A command-line password is visible in shell history and to every other process on the machine via
|
||||
* the process list, and an environment variable tends to become permanent — a bootstrap password
|
||||
* belongs to one invocation, not to the deployment's configuration, so this command deliberately
|
||||
* reads no environment variable for it and none is added to `.env.example`.
|
||||
*
|
||||
* Interactive use asks twice and compares, because the field is invisible and a typo would create
|
||||
* an owner nobody can sign in as, on an installation whose one bootstrap has now been consumed.
|
||||
*/
|
||||
async function readPassword(): Promise<string> {
|
||||
if (process.stdin.isTTY === true) {
|
||||
const password = await promptHidden("Password: ");
|
||||
const confirmation = await promptHidden("Confirm password: ");
|
||||
if (password !== confirmation) {
|
||||
throw new InitialOwnerCliError("The passwords did not match. No changes were made.");
|
||||
}
|
||||
return password;
|
||||
}
|
||||
// A single trailing newline is stripped so `printf '%s\n'`, a here-string, and an editor-produced
|
||||
// file all behave the same; nothing else is trimmed, because leading and trailing spaces are
|
||||
// legitimate password characters.
|
||||
const piped = await readStdin();
|
||||
return piped.replace(/\r?\n$/u, "");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { email, name } = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Read only when this command actually executes — never merely by importing this module.
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||
throw new InitialOwnerCliError("DATABASE_URL is required.");
|
||||
}
|
||||
|
||||
const password = await readPassword();
|
||||
if (password.length === 0) {
|
||||
throw new InitialOwnerCliError("A password is required. No changes were made.");
|
||||
}
|
||||
|
||||
// Asserted before any connection is constructed, so an unsafe production transport never opens a
|
||||
// client at all — identical to every other Core executable.
|
||||
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||
const databaseOptions = {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 30_000,
|
||||
transport,
|
||||
};
|
||||
const authorization = createAuthorizationDrizzleDatabase(databaseUrl, databaseOptions);
|
||||
const identity = createIdentityDrizzleDatabase(databaseUrl, databaseOptions);
|
||||
try {
|
||||
const result = await createInitialPlatformOwner(
|
||||
{
|
||||
authorizationDb: authorization.db,
|
||||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||
identityDb: identity.db,
|
||||
},
|
||||
{ email, name: name ?? email, password },
|
||||
);
|
||||
|
||||
// Never the password, its length, a hash, a session token, a user id, or the database URL.
|
||||
console.log(
|
||||
result.identity === "created"
|
||||
? "Initial platform owner created successfully."
|
||||
: "An account already existed for that address; platform ownership was granted to it and its password was left unchanged.",
|
||||
);
|
||||
console.log(`Email: ${result.email}`);
|
||||
console.log("You can now sign in to the Ekklesia dashboard.");
|
||||
} finally {
|
||||
await Promise.all([
|
||||
authorization.close().catch(() => undefined),
|
||||
identity.close().catch(() => undefined),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function reportInitialOwnerCliError(error: unknown): void {
|
||||
if (error instanceof InitialOwnerCliError) {
|
||||
console.error(error.message);
|
||||
return;
|
||||
}
|
||||
if (error instanceof InitialPlatformOwnerError) {
|
||||
console.error(`[${error.code}] ${error.message}`);
|
||||
return;
|
||||
}
|
||||
// Deliberately generic: the caught value may carry SQL, a connection string, a driver detail, or
|
||||
// the password itself if it were ever interpolated into a message upstream.
|
||||
console.error("Initial platform owner creation failed unexpectedly.");
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch((error: unknown) => {
|
||||
reportInitialOwnerCliError(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -4,35 +4,28 @@ import {
|
||||
type DatabaseTransportPolicy,
|
||||
createDatabaseTransportPolicy,
|
||||
} from "../database/transport.js";
|
||||
import { loadHostConfiguration } from "../host/index.js";
|
||||
|
||||
/**
|
||||
* The one place every Core-owned executable reads its database transport policy from the process
|
||||
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and
|
||||
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`.
|
||||
* The database transport policy for the small Core-owned executables — `pnpm migrations:plan`,
|
||||
* `pnpm migrations:apply`, `pnpm modules:enable`, and the owner-bootstrap commands — resolved from
|
||||
* the process environment.
|
||||
*
|
||||
* Environment reading lives here, in the executable layer, deliberately: the reusable Core
|
||||
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`.
|
||||
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string
|
||||
* `"development"` relaxes anything, so an unset or misspelled value validates as production.
|
||||
* The mapping itself belongs to `loadHostConfiguration`, which `startApi` and `pnpm doctor` also
|
||||
* call, so `NODE_ENV` and the two TLS variables have exactly one interpretation across every entry
|
||||
* point. Environment *reading* still happens here, in the executable layer: the reusable Core
|
||||
* database and migration constructors keep taking explicit typed inputs and never touch
|
||||
* `process.env`.
|
||||
*/
|
||||
export function readDatabaseTransportFromEnvironment(): {
|
||||
readonly environment: DatabaseTransportEnvironment;
|
||||
readonly transport: DatabaseTransportPolicy;
|
||||
} {
|
||||
const environment: DatabaseTransportEnvironment =
|
||||
process.env.NODE_ENV === "development" ? "development" : "production";
|
||||
const transport = createDatabaseTransportPolicy(
|
||||
{
|
||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
||||
? {}
|
||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||
? {}
|
||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
||||
},
|
||||
{ environment },
|
||||
);
|
||||
return { environment, transport };
|
||||
const host = loadHostConfiguration(process.env);
|
||||
const transport = createDatabaseTransportPolicy(host.databaseTransport, {
|
||||
environment: host.environment,
|
||||
});
|
||||
return { environment: host.environment, transport };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+53
-54
@@ -2,11 +2,12 @@ import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
diagnoseEkklesia,
|
||||
type DoctorOptions,
|
||||
diagnoseEkklesia,
|
||||
doctorExitCode,
|
||||
formatDoctorReport,
|
||||
} from "../diagnostics/index.js";
|
||||
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
||||
|
||||
interface ModuleSdkPackageDocument {
|
||||
readonly version?: unknown;
|
||||
@@ -18,6 +19,18 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything Doctor needs that comes from the repository itself rather than the deployment
|
||||
* environment, resolved by the caller so the environment mapping below stays pure and testable.
|
||||
*/
|
||||
export interface DoctorCommandMetadata {
|
||||
readonly moduleSdkVersion: string;
|
||||
readonly modulesDirectory: string;
|
||||
readonly platformVersion: string;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
}
|
||||
|
||||
async function readJson(pathname: string): Promise<unknown> {
|
||||
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
||||
}
|
||||
@@ -36,40 +49,40 @@ function requiredString(value: unknown): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
|
||||
const secret = process.env.EKKLESIA_IDENTITY_SECRET;
|
||||
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
|
||||
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED;
|
||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||
const trustedOrigins = trustedOriginsValue
|
||||
? trustedOriginsValue
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Turns one deployment environment into Doctor's inputs.
|
||||
*
|
||||
* Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
|
||||
* with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
|
||||
* deployment is configured to use. Doctor previously read its own subset here and silently omitted
|
||||
* object storage and module secrets entirely, which made it report a correctly configured
|
||||
* installation as having neither.
|
||||
*
|
||||
* Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
|
||||
* malformed value appears in the report — which is the whole reason an operator runs this command —
|
||||
* instead of throwing before a report exists.
|
||||
*/
|
||||
export function createDoctorOptions(
|
||||
environment: HostEnvironment,
|
||||
metadata: DoctorCommandMetadata,
|
||||
): DoctorOptions {
|
||||
const host = loadHostConfiguration(environment);
|
||||
return {
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||
...(publicSignupEnabled ? { publicSignupEnabled } : {}),
|
||||
...(secret ? { secret } : {}),
|
||||
...(trustedOrigins ? { trustedOrigins } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] {
|
||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
||||
if (mode === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
|
||||
return {
|
||||
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
||||
mode,
|
||||
trustedProxies: trustedProxiesValue
|
||||
?.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0),
|
||||
clientAddress: host.clientAddress,
|
||||
databaseEnvironment: host.environment,
|
||||
databaseTransport: host.databaseTransport,
|
||||
databaseUrl: host.databaseUrl,
|
||||
identityConfiguration: { ...host.identity, environment: host.environment },
|
||||
mailConfiguration: host.mail,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: metadata.moduleSdkVersion,
|
||||
modulesDirectory: metadata.modulesDirectory,
|
||||
platformVersion: metadata.platformVersion,
|
||||
},
|
||||
moduleSecrets: host.moduleSecrets,
|
||||
runtimeRange: metadata.runtimeRange,
|
||||
runtimeVersion: metadata.runtimeVersion,
|
||||
storageConfiguration: host.storage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,29 +94,15 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
||||
const rootPackage = rootPackageDocument(rootValue);
|
||||
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
||||
|
||||
const databaseEnvironment =
|
||||
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const);
|
||||
const report = await diagnoseEkklesia({
|
||||
clientAddress: readClientAddressFromEnvironment(),
|
||||
databaseEnvironment,
|
||||
databaseTransport: {
|
||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
||||
? {}
|
||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||
? {}
|
||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
||||
},
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
identityConfiguration: readIdentityConfigurationFromEnvironment(),
|
||||
moduleDiscovery: {
|
||||
const report = await diagnoseEkklesia(
|
||||
createDoctorOptions(process.env, {
|
||||
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
platformVersion: requiredString(rootPackage.version),
|
||||
},
|
||||
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||
runtimeVersion: process.versions.node,
|
||||
});
|
||||
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||
runtimeVersion: process.versions.node,
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(formatDoctorReport(report).join("\n"));
|
||||
return doctorExitCode(report);
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { PermissionRegistryError } from "../authorization/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModuleSchemas,
|
||||
ModuleDiscoveryError,
|
||||
ModulePermissionCompositionError,
|
||||
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Built from the same registry the API publishes from, so this command can never disagree with
|
||||
// what a running platform would actually serve. A contribution whose target capability nobody
|
||||
// provides — or whose provider moved outside its declared range — is otherwise silent: the
|
||||
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
|
||||
const publication = createModuleUiPublicationRegistry(modules);
|
||||
const compatibleContributions = new Set(
|
||||
publication
|
||||
.listContributions()
|
||||
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
||||
);
|
||||
for (const discoveredModule of modules) {
|
||||
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
||||
for (const contribution of discoveredModule.contributions) {
|
||||
const resolved = compatibleContributions.has(
|
||||
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
|
||||
);
|
||||
console.log(
|
||||
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
|
||||
resolved ? "compatible provider installed" : "no compatible provider installed"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
import { deviceSchema } from "./schema.js";
|
||||
|
||||
export type DeviceDrizzleDatabase = NodePgDatabase<typeof deviceSchema>;
|
||||
|
||||
/**
|
||||
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`.
|
||||
*
|
||||
* There is deliberately no pool-constructing counterpart: device identity is only ever read and
|
||||
* written by the running API, which already owns one validated pool. A second constructor would be
|
||||
* a second place for transport policy to drift.
|
||||
*/
|
||||
export function createDeviceDrizzleView(pool: Pool): DeviceDrizzleDatabase {
|
||||
return drizzle(pool, { schema: deviceSchema });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./database.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./store.js";
|
||||
@@ -0,0 +1,168 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||
|
||||
/**
|
||||
* Core-owned device identity: paired display devices, the permissions granted to them, and the
|
||||
* short-lived pairing requests that create them (ADR 0036).
|
||||
*
|
||||
* **Devices are not users.** There is deliberately no foreign key from a device to
|
||||
* `ekklesia_core_user`, and no device row ever appears in the identity tables: a device is its own
|
||||
* principal class with its own credential and its own explicit permissions. The only reference to a
|
||||
* user here is *who approved a pairing*, which is audit information about a person's action, not a
|
||||
* device's identity.
|
||||
*
|
||||
* Device permissions live in their own table rather than in `ekklesia_core_grant`. That table's
|
||||
* `user_id` is a non-null foreign key into the user table, and generalizing it to a polymorphic
|
||||
* subject would rewrite the platform's central authorization record — and its relationship-derived
|
||||
* grants, audit semantics, and indexes — to serve one narrow case. A separate table keeps the
|
||||
* device story additive and auditable while the evaluator remains the single place any decision is
|
||||
* made (`core/src/authorization/evaluator.ts`).
|
||||
*/
|
||||
|
||||
export const ekklesiaCoreDevice = pgTable(
|
||||
"ekklesia_core_device",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** Assigned by the approving administrator, never self-asserted by the unpaired device. */
|
||||
name: text("name").notNull(),
|
||||
/**
|
||||
* SHA-256 of the device's credential, hex encoded. The raw credential exists only in the
|
||||
* response that sets the cookie and in the device's own cookie jar; it is never stored, logged,
|
||||
* or recoverable from this row. A fast hash is right here — unlike a password, the credential
|
||||
* is 256 bits of CSPRNG output, so there is nothing to brute-force and a slow KDF would only
|
||||
* add latency to every request a display makes.
|
||||
*/
|
||||
credentialHash: text("credential_hash").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdByUserId: text("created_by_user_id").references(() => ekklesiaCoreUser.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** Coarse operational signal, throttled by the resolver; never an identity input. */
|
||||
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
||||
/** Server-side authority. Revocation is this column, never the deletion of a remote cookie. */
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
revokedByUserId: text("revoked_by_user_id").references(() => ekklesiaCoreUser.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
// Every credential is distinct, and the resolver looks a presented credential up by this exact
|
||||
// hash — an index probe rather than a scan-and-compare, so there is no comparison to time.
|
||||
uniqueIndex("ekklesia_core_device_credential_hash_idx").on(table.credentialHash),
|
||||
index("ekklesia_core_device_revoked_idx").on(table.revokedAt),
|
||||
check("ekklesia_core_device_name_check", sql`length(${table.name}) between 1 and 128`),
|
||||
check("ekklesia_core_device_credential_hash_check", sql`length(${table.credentialHash}) = 64`),
|
||||
],
|
||||
);
|
||||
|
||||
export const ekklesiaCoreDevicePermission = pgTable(
|
||||
"ekklesia_core_device_permission",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
deviceId: uuid("device_id")
|
||||
.notNull()
|
||||
.references(() => ekklesiaCoreDevice.id, { onDelete: "cascade" }),
|
||||
permissionId: text("permission_id").notNull(),
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
grantedByUserId: text("granted_by_user_id").references(() => ekklesiaCoreUser.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index("ekklesia_core_device_permission_lookup_idx").on(table.deviceId, table.permissionId),
|
||||
check(
|
||||
"ekklesia_core_device_permission_id_check",
|
||||
sql`length(${table.permissionId}) between 1 and 128`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One in-flight pairing attempt.
|
||||
*
|
||||
* Three distinct secrets meet here and must not be confused:
|
||||
*
|
||||
* - `code` is the short digits shown on the screen. It is **public by design** — it is displayed on
|
||||
* a television in a hallway — and it identifies the request to an administrator. It is stored in
|
||||
* the clear because it is not a secret and because an administrator looks a request up by it.
|
||||
* - `claimSecretHash` is SHA-256 of a 256-bit secret held only in the requesting browser's
|
||||
* HttpOnly cookie. It answers "are you the device that asked?", which the code cannot: anyone who
|
||||
* can *see* the screen knows the code, so a flow that issued a credential on the strength of the
|
||||
* code alone would hand the device's identity to whoever read it off the wall.
|
||||
* - The device credential does not exist yet, and is created only when the approved request is
|
||||
* consumed by the browser holding the claim secret.
|
||||
*
|
||||
* `expiresAt` is the only expiry authority; there is no `expired` state to write and therefore no
|
||||
* sweeper that must run for correctness. `state` records only what someone *did* — nothing here
|
||||
* ages a row on its own.
|
||||
*/
|
||||
export const ekklesiaCoreDevicePairingRequest = pgTable(
|
||||
"ekklesia_core_device_pairing_request",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
code: text("code").notNull(),
|
||||
claimSecretHash: text("claim_secret_hash").notNull(),
|
||||
/** The display the device actually navigated to, resolved server-side from the publication
|
||||
* registry. Never a value the browser chose. */
|
||||
moduleId: text("module_id").notNull(),
|
||||
/** Derived from that module's validated display declaration, so a device cannot ask for a
|
||||
* permission of its choosing. */
|
||||
permissionId: text("permission_id").notNull(),
|
||||
state: text("state").notNull().default("pending"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
/** Chosen by the approving administrator; carried here until the request is consumed and the
|
||||
* device row is created. */
|
||||
deviceName: text("device_name"),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
approvedByUserId: text("approved_by_user_id").references(() => ekklesiaCoreUser.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
consumedAt: timestamp("consumed_at", { withTimezone: true }),
|
||||
deviceId: uuid("device_id").references(() => ekklesiaCoreDevice.id, { onDelete: "set null" }),
|
||||
},
|
||||
(table) => [
|
||||
// At most one live request may hold a given code, which is what makes a short code safe to
|
||||
// generate: a collision is refused by PostgreSQL rather than resolved by whichever row a query
|
||||
// happened to return first. Consumed rows keep their code without blocking reuse later.
|
||||
uniqueIndex("ekklesia_core_device_pairing_code_idx")
|
||||
.on(table.code)
|
||||
.where(sql`${table.state} <> 'consumed'`),
|
||||
index("ekklesia_core_device_pairing_claim_idx").on(table.claimSecretHash),
|
||||
check("ekklesia_core_device_pairing_code_check", sql`${table.code} ~ '^[0-9]{6}$'`),
|
||||
check(
|
||||
"ekklesia_core_device_pairing_claim_hash_check",
|
||||
sql`length(${table.claimSecretHash}) = 64`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_device_pairing_state_check",
|
||||
sql`${table.state} in ('pending', 'approved', 'consumed')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const ekklesiaCoreDeviceRelations = relations(ekklesiaCoreDevice, ({ many }) => ({
|
||||
permissions: many(ekklesiaCoreDevicePermission),
|
||||
}));
|
||||
|
||||
export const ekklesiaCoreDevicePermissionRelations = relations(
|
||||
ekklesiaCoreDevicePermission,
|
||||
({ one }) => ({
|
||||
device: one(ekklesiaCoreDevice, {
|
||||
fields: [ekklesiaCoreDevicePermission.deviceId],
|
||||
references: [ekklesiaCoreDevice.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
/** The device tables, bundled exactly like `authorizationSchema`, so a Drizzle view over the
|
||||
* platform's shared pool is typed against them. */
|
||||
export const deviceSchema = {
|
||||
ekklesiaCoreDevice,
|
||||
ekklesiaCoreDevicePairingRequest,
|
||||
ekklesiaCoreDevicePermission,
|
||||
ekklesiaCoreDevicePermissionRelations,
|
||||
ekklesiaCoreDeviceRelations,
|
||||
};
|
||||
@@ -0,0 +1,552 @@
|
||||
import { createHash, randomBytes, randomInt } from "node:crypto";
|
||||
import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||
import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
|
||||
import type { DeviceDrizzleDatabase } from "./database.js";
|
||||
import {
|
||||
ekklesiaCoreDevice,
|
||||
ekklesiaCoreDevicePairingRequest,
|
||||
ekklesiaCoreDevicePermission,
|
||||
} from "./schema.js";
|
||||
|
||||
export type DeviceStoreErrorCode =
|
||||
| "DEVICE_PAIRING_CODE_UNAVAILABLE"
|
||||
| "DEVICE_STORE_FAILED"
|
||||
| "DEVICE_NOT_FOUND";
|
||||
|
||||
const deviceStoreMessages: Readonly<Record<DeviceStoreErrorCode, string>> = Object.freeze({
|
||||
DEVICE_NOT_FOUND: "The device does not exist.",
|
||||
DEVICE_PAIRING_CODE_UNAVAILABLE: "A pairing code could not be allocated. Try again.",
|
||||
DEVICE_STORE_FAILED: "The device operation could not be completed.",
|
||||
});
|
||||
|
||||
/** Never carries a credential, a claim secret, SQL, a driver error, or a cause. */
|
||||
export class DeviceStoreError extends Error {
|
||||
readonly code: DeviceStoreErrorCode;
|
||||
|
||||
constructor(code: DeviceStoreErrorCode) {
|
||||
super(deviceStoreMessages[code]);
|
||||
this.name = "DeviceStoreError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a pairing code is worth showing on a screen.
|
||||
*
|
||||
* Long enough that someone can walk from the display to a computer and type six digits; short
|
||||
* enough that a code photographed off a wall is worthless by the time anyone acts on it. It is not
|
||||
* the only thing protecting pairing — approval requires an authenticated administrator holding
|
||||
* `core.devices.manage`, and the credential is issued only to the browser holding the claim secret
|
||||
* — so this bound is defence in depth rather than the defence.
|
||||
*/
|
||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* How long a device credential is accepted before the device must pair again.
|
||||
*
|
||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
||||
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
||||
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
|
||||
*/
|
||||
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
||||
|
||||
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||
|
||||
const PAIRING_CODE_ALLOCATION_ATTEMPTS = 8;
|
||||
|
||||
/**
|
||||
* How many pairing requests may be waiting at once, across the whole installation.
|
||||
*
|
||||
* Starting a pairing needs no identity — an unpaired screen has none to offer — so without a ceiling
|
||||
* anyone who can reach the platform could fill this table. A building has a handful of screens and
|
||||
* pairs them one at a time, so this is far above any real use and still a hard bound. Expired rows
|
||||
* are swept on the way in, so the ceiling counts genuinely live requests rather than history.
|
||||
*/
|
||||
const MAX_LIVE_PAIRING_REQUESTS = 50;
|
||||
|
||||
/** SHA-256, hex. Applied to 256-bit CSPRNG output, never to anything user-chosen. */
|
||||
export function hashDeviceSecret(secret: string): string {
|
||||
return createHash("sha256").update(secret, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/** 256 bits, URL-safe. Used for both the pairing claim secret and the device credential — the two
|
||||
* values that must never be guessable. */
|
||||
function generateSecret(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
/** Six digits from a rejection-free CSPRNG range, zero padded so every code is exactly six
|
||||
* characters and none is easier to type than another. */
|
||||
function generatePairingCode(): string {
|
||||
return randomInt(0, 1_000_000).toString().padStart(6, "0");
|
||||
}
|
||||
|
||||
export interface CreatePairingRequestInput {
|
||||
readonly moduleId: string;
|
||||
readonly permissionId: PermissionId;
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
export interface CreatedPairingRequest {
|
||||
readonly code: string;
|
||||
readonly expiresAt: Date;
|
||||
/** Returned exactly once, to be written into the device's HttpOnly cookie and never persisted or
|
||||
* logged in this form. */
|
||||
readonly claimSecret: string;
|
||||
}
|
||||
|
||||
export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown";
|
||||
|
||||
export interface PairingRequestView {
|
||||
readonly moduleId: string;
|
||||
readonly permissionId: string;
|
||||
readonly createdAt: Date;
|
||||
readonly expiresAt: Date;
|
||||
readonly status: "pending" | "approved";
|
||||
}
|
||||
|
||||
export interface ConsumedPairing {
|
||||
readonly deviceId: EkklesiaDeviceId;
|
||||
/** Returned exactly once, to be written into the device's HttpOnly cookie. */
|
||||
readonly credential: string;
|
||||
readonly expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface DeviceSummary {
|
||||
readonly id: EkklesiaDeviceId;
|
||||
readonly name: string;
|
||||
readonly createdAt: Date;
|
||||
readonly lastSeenAt?: Date;
|
||||
readonly revokedAt?: Date;
|
||||
readonly permissions: readonly string[];
|
||||
}
|
||||
|
||||
export interface ResolvedDevice {
|
||||
readonly deviceId: EkklesiaDeviceId;
|
||||
readonly lastSeenAt?: Date;
|
||||
}
|
||||
|
||||
export interface DeviceStore {
|
||||
createPairingRequest(input: CreatePairingRequestInput): Promise<CreatedPairingRequest>;
|
||||
/** Looks a live request up by the code an administrator typed. Never used by the device itself. */
|
||||
findPendingByCode(code: string, now: Date): Promise<PairingRequestView | undefined>;
|
||||
approvePairingRequest(input: {
|
||||
readonly code: string;
|
||||
readonly deviceName: string;
|
||||
readonly approvedBy: EkklesiaUserId;
|
||||
readonly now: Date;
|
||||
}): Promise<PairingRequestView | undefined>;
|
||||
/** The device's own poll. Consumes an approved request atomically and issues the credential. */
|
||||
claimPairingRequest(input: {
|
||||
readonly claimSecret: string;
|
||||
readonly now: Date;
|
||||
}): Promise<{ readonly status: PairingRequestStatus; readonly paired?: ConsumedPairing }>;
|
||||
/** Resolves a presented credential to a live, unrevoked device. */
|
||||
resolveCredential(credential: string, now: Date): Promise<ResolvedDevice | undefined>;
|
||||
touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise<void>;
|
||||
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
|
||||
listDevices(): Promise<readonly DeviceSummary[]>;
|
||||
revokeDevice(input: {
|
||||
readonly deviceId: EkklesiaDeviceId;
|
||||
readonly revokedBy: EkklesiaUserId;
|
||||
readonly now: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
function rethrowAsStoreError(error: unknown): never {
|
||||
if (error instanceof DeviceStoreError) {
|
||||
throw error;
|
||||
}
|
||||
throw new DeviceStoreError("DEVICE_STORE_FAILED");
|
||||
}
|
||||
|
||||
export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
/** Deletes rows that can no longer do anything: expired-and-never-approved requests, and
|
||||
* consumed ones past their window. Run opportunistically on request creation rather than by a
|
||||
* scheduler — pairing is rare, so the only moment the table can grow is also a fine moment to
|
||||
* trim it, and nothing depends on it having run. */
|
||||
const sweepDeadRequests = async (now: Date): Promise<void> => {
|
||||
await db
|
||||
.delete(ekklesiaCoreDevicePairingRequest)
|
||||
.where(
|
||||
or(
|
||||
and(
|
||||
lt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
|
||||
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||
),
|
||||
lt(ekklesiaCoreDevicePairingRequest.expiresAt, new Date(now.getTime() - 86_400_000)),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const store: DeviceStore = {
|
||||
async approvePairingRequest(input): Promise<PairingRequestView | undefined> {
|
||||
try {
|
||||
// Conditional on `pending` and unexpired, so approving twice, approving an expired request,
|
||||
// or approving one already consumed all affect zero rows rather than racing.
|
||||
const rows = await db
|
||||
.update(ekklesiaCoreDevicePairingRequest)
|
||||
.set({
|
||||
approvedAt: input.now,
|
||||
approvedByUserId: input.approvedBy,
|
||||
deviceName: input.deviceName,
|
||||
state: "approved",
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevicePairingRequest.code, input.code),
|
||||
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
|
||||
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
|
||||
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
|
||||
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
|
||||
});
|
||||
const row = rows[0];
|
||||
return row === undefined
|
||||
? undefined
|
||||
: Object.freeze({ ...row, status: "approved" as const });
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async claimPairingRequest(input): Promise<{
|
||||
readonly status: PairingRequestStatus;
|
||||
readonly paired?: ConsumedPairing;
|
||||
}> {
|
||||
const claimSecretHash = hashDeviceSecret(input.claimSecret);
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
.select({
|
||||
deviceName: ekklesiaCoreDevicePairingRequest.deviceName,
|
||||
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
|
||||
id: ekklesiaCoreDevicePairingRequest.id,
|
||||
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
|
||||
state: ekklesiaCoreDevicePairingRequest.state,
|
||||
approvedByUserId: ekklesiaCoreDevicePairingRequest.approvedByUserId,
|
||||
})
|
||||
.from(ekklesiaCoreDevicePairingRequest)
|
||||
.where(eq(ekklesiaCoreDevicePairingRequest.claimSecretHash, claimSecretHash))
|
||||
.limit(1);
|
||||
const request = rows[0];
|
||||
if (!request) {
|
||||
return { status: "unknown" as const };
|
||||
}
|
||||
if (request.state === "consumed") {
|
||||
// A credential was already issued for this request. Replaying the claim never issues a
|
||||
// second one.
|
||||
return { status: "unknown" as const };
|
||||
}
|
||||
if (request.expiresAt.getTime() <= input.now.getTime()) {
|
||||
return { status: "expired" as const };
|
||||
}
|
||||
if (request.state !== "approved") {
|
||||
return { status: "pending" as const };
|
||||
}
|
||||
|
||||
// The single-use gate: only the transaction whose UPDATE actually matches the still
|
||||
// `approved` row proceeds to mint a credential. A concurrent second claim matches zero
|
||||
// rows and is told the request is gone.
|
||||
const consumed = await tx
|
||||
.update(ekklesiaCoreDevicePairingRequest)
|
||||
.set({ consumedAt: input.now, state: "consumed" })
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevicePairingRequest.id, request.id),
|
||||
eq(ekklesiaCoreDevicePairingRequest.state, "approved"),
|
||||
),
|
||||
)
|
||||
.returning({ id: ekklesiaCoreDevicePairingRequest.id });
|
||||
if (consumed.length === 0) {
|
||||
return { status: "unknown" as const };
|
||||
}
|
||||
|
||||
const credential = generateSecret();
|
||||
const created = await tx
|
||||
.insert(ekklesiaCoreDevice)
|
||||
.values({
|
||||
createdByUserId: request.approvedByUserId,
|
||||
credentialHash: hashDeviceSecret(credential),
|
||||
name: request.deviceName ?? "Display device",
|
||||
})
|
||||
.returning({ id: ekklesiaCoreDevice.id });
|
||||
const deviceId = created[0]?.id;
|
||||
if (deviceId === undefined) {
|
||||
throw new DeviceStoreError("DEVICE_STORE_FAILED");
|
||||
}
|
||||
|
||||
// Exactly the one permission the request was created for — never a bundle, never every
|
||||
// permission the module declares.
|
||||
await tx.insert(ekklesiaCoreDevicePermission).values({
|
||||
deviceId,
|
||||
grantedByUserId: request.approvedByUserId,
|
||||
permissionId: request.permissionId,
|
||||
});
|
||||
|
||||
await tx
|
||||
.update(ekklesiaCoreDevicePairingRequest)
|
||||
.set({ deviceId })
|
||||
.where(eq(ekklesiaCoreDevicePairingRequest.id, request.id));
|
||||
|
||||
return {
|
||||
paired: Object.freeze({
|
||||
credential,
|
||||
deviceId: deviceId as EkklesiaDeviceId,
|
||||
expiresAt: new Date(input.now.getTime() + DEVICE_CREDENTIAL_TTL_MS),
|
||||
}),
|
||||
status: "pending" as const,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async createPairingRequest(input): Promise<CreatedPairingRequest> {
|
||||
await sweepDeadRequests(input.now);
|
||||
const live = await db
|
||||
.select({ id: ekklesiaCoreDevicePairingRequest.id })
|
||||
.from(ekklesiaCoreDevicePairingRequest)
|
||||
.where(
|
||||
and(
|
||||
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
|
||||
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(MAX_LIVE_PAIRING_REQUESTS);
|
||||
if (live.length >= MAX_LIVE_PAIRING_REQUESTS) {
|
||||
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
|
||||
}
|
||||
const claimSecret = generateSecret();
|
||||
const expiresAt = new Date(input.now.getTime() + DEVICE_PAIRING_REQUEST_TTL_MS);
|
||||
// Collisions are resolved by the partial unique index, not by reading first and hoping: a
|
||||
// concurrent request that took the same code makes this insert fail and we simply draw again.
|
||||
for (let attempt = 0; attempt < PAIRING_CODE_ALLOCATION_ATTEMPTS; attempt += 1) {
|
||||
const code = generatePairingCode();
|
||||
try {
|
||||
await db.insert(ekklesiaCoreDevicePairingRequest).values({
|
||||
claimSecretHash: hashDeviceSecret(claimSecret),
|
||||
code,
|
||||
expiresAt,
|
||||
moduleId: input.moduleId,
|
||||
permissionId: input.permissionId,
|
||||
});
|
||||
return Object.freeze({ claimSecret, code, expiresAt });
|
||||
} catch {
|
||||
// Fall through and draw a different code.
|
||||
}
|
||||
}
|
||||
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
|
||||
},
|
||||
|
||||
async findPendingByCode(code, now): Promise<PairingRequestView | undefined> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
|
||||
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
|
||||
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
|
||||
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
|
||||
state: ekklesiaCoreDevicePairingRequest.state,
|
||||
})
|
||||
.from(ekklesiaCoreDevicePairingRequest)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevicePairingRequest.code, code),
|
||||
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||
gt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row === undefined
|
||||
? undefined
|
||||
: Object.freeze({
|
||||
createdAt: row.createdAt,
|
||||
expiresAt: row.expiresAt,
|
||||
moduleId: row.moduleId,
|
||||
permissionId: row.permissionId,
|
||||
status: "pending" as const,
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async hasActivePermission(deviceId, permission): Promise<boolean> {
|
||||
try {
|
||||
// Joined against the device row so a revoked device has no permissions at all, without
|
||||
// every permission row needing to be revoked separately.
|
||||
const rows = await db
|
||||
.select({ id: ekklesiaCoreDevicePermission.id })
|
||||
.from(ekklesiaCoreDevicePermission)
|
||||
.innerJoin(
|
||||
ekklesiaCoreDevice,
|
||||
eq(ekklesiaCoreDevice.id, ekklesiaCoreDevicePermission.deviceId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevicePermission.deviceId, deviceId),
|
||||
eq(ekklesiaCoreDevicePermission.permissionId, permission),
|
||||
isNull(ekklesiaCoreDevicePermission.revokedAt),
|
||||
isNull(ekklesiaCoreDevice.revokedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async listDevices(): Promise<readonly DeviceSummary[]> {
|
||||
try {
|
||||
const devices = await db
|
||||
.select({
|
||||
createdAt: ekklesiaCoreDevice.createdAt,
|
||||
id: ekklesiaCoreDevice.id,
|
||||
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
|
||||
name: ekklesiaCoreDevice.name,
|
||||
revokedAt: ekklesiaCoreDevice.revokedAt,
|
||||
})
|
||||
.from(ekklesiaCoreDevice)
|
||||
.orderBy(ekklesiaCoreDevice.createdAt);
|
||||
const permissions = await db
|
||||
.select({
|
||||
deviceId: ekklesiaCoreDevicePermission.deviceId,
|
||||
permissionId: ekklesiaCoreDevicePermission.permissionId,
|
||||
})
|
||||
.from(ekklesiaCoreDevicePermission)
|
||||
.where(isNull(ekklesiaCoreDevicePermission.revokedAt));
|
||||
return Object.freeze(
|
||||
devices.map((device) =>
|
||||
Object.freeze({
|
||||
createdAt: device.createdAt,
|
||||
id: device.id as EkklesiaDeviceId,
|
||||
name: device.name,
|
||||
permissions: Object.freeze(
|
||||
permissions
|
||||
.filter((permission) => permission.deviceId === device.id)
|
||||
.map((permission) => permission.permissionId)
|
||||
.sort(),
|
||||
),
|
||||
...(device.lastSeenAt === null ? {} : { lastSeenAt: device.lastSeenAt }),
|
||||
...(device.revokedAt === null ? {} : { revokedAt: device.revokedAt }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async resolveCredential(credential, now): Promise<ResolvedDevice | undefined> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
createdAt: ekklesiaCoreDevice.createdAt,
|
||||
id: ekklesiaCoreDevice.id,
|
||||
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
|
||||
})
|
||||
.from(ekklesiaCoreDevice)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevice.credentialHash, hashDeviceSecret(credential)),
|
||||
isNull(ekklesiaCoreDevice.revokedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||
// checked above and is what makes a long horizon acceptable.
|
||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
deviceId: row.id as EkklesiaDeviceId,
|
||||
...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }),
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async revokeDevice(input): Promise<void> {
|
||||
try {
|
||||
const rows = await db
|
||||
.update(ekklesiaCoreDevice)
|
||||
.set({ revokedAt: input.now, revokedByUserId: input.revokedBy })
|
||||
.where(
|
||||
and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)),
|
||||
)
|
||||
.returning({ id: ekklesiaCoreDevice.id });
|
||||
if (rows.length === 0) {
|
||||
// Either no such device or already revoked. Both are "not an active device", and telling
|
||||
// the two apart would only serve enumeration.
|
||||
const existing = await db
|
||||
.select({ id: ekklesiaCoreDevice.id })
|
||||
.from(ekklesiaCoreDevice)
|
||||
.where(eq(ekklesiaCoreDevice.id, input.deviceId))
|
||||
.limit(1);
|
||||
if (existing.length === 0) {
|
||||
throw new DeviceStoreError("DEVICE_NOT_FOUND");
|
||||
}
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.update(ekklesiaCoreDevicePermission)
|
||||
.set({ revokedAt: input.now })
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId),
|
||||
isNull(ekklesiaCoreDevicePermission.revokedAt),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
rethrowAsStoreError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async touchLastSeen(deviceId, now): Promise<void> {
|
||||
// Throttled in SQL so concurrent requests from one display cannot each decide to write.
|
||||
await db
|
||||
.update(ekklesiaCoreDevice)
|
||||
.set({ lastSeenAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreDevice.id, deviceId),
|
||||
or(
|
||||
isNull(ekklesiaCoreDevice.lastSeenAt),
|
||||
lt(
|
||||
ekklesiaCoreDevice.lastSeenAt,
|
||||
new Date(now.getTime() - DEVICE_LAST_SEEN_THROTTLE_MS),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
},
|
||||
};
|
||||
return Object.freeze(store);
|
||||
}
|
||||
|
||||
/** Exported for the schema-drift check and tests; never used to build a query string by hand. */
|
||||
export const DEVICE_PAIRING_CODE_PATTERN = /^[0-9]{6}$/u;
|
||||
|
||||
export { sql as deviceStoreSql };
|
||||
@@ -22,8 +22,22 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
||||
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||
section("Identity", ["identity.configuration", "identity.client-address"]),
|
||||
section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
|
||||
section("Outbound email", ["outbound-email.configuration"]),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Every check id, exactly once.
|
||||
*
|
||||
* A check absent from `sections` is still computed and still counted toward the overall result, but
|
||||
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
|
||||
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
|
||||
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
|
||||
* cannot be added without being rendered.
|
||||
*/
|
||||
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
|
||||
sections.flatMap((entry) => [...entry.checkIds]),
|
||||
);
|
||||
|
||||
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
|
||||
failure: "✗",
|
||||
pass: "✓",
|
||||
|
||||
+272
-35
@@ -4,25 +4,26 @@ import {
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
isVersionCompatible,
|
||||
logicalBucketIdSchema,
|
||||
type ModuleSecretSource,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
type ModuleSecretSource,
|
||||
permissionIdSchema,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
||||
import type { PostgresPoolOptions } from "../database/pool.js";
|
||||
import {
|
||||
assertProductionDatabaseTransport,
|
||||
type DatabaseTransportEnvironment,
|
||||
type DatabaseTransportPolicyInput,
|
||||
DatabaseTransportError,
|
||||
createDatabaseTransportPolicy,
|
||||
type DatabaseTransportEnvironment,
|
||||
DatabaseTransportError,
|
||||
type DatabaseTransportPolicyInput,
|
||||
describeDatabaseTransport,
|
||||
} from "../database/transport.js";
|
||||
import {
|
||||
type ClientAddressConfigInput,
|
||||
ClientAddressConfigError,
|
||||
type ClientAddressConfigInput,
|
||||
createClientAddressConfig,
|
||||
} from "../identity/client-address.js";
|
||||
import {
|
||||
@@ -32,7 +33,14 @@ import {
|
||||
} from "../identity/config.js";
|
||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
||||
import { identityAdapterSchema } from "../identity/schema.js";
|
||||
import type { PostgresPoolOptions } from "../database/pool.js";
|
||||
import {
|
||||
createMailProviderConfig,
|
||||
createSmtpMailProvider,
|
||||
describeMailProvider,
|
||||
MailConfigError,
|
||||
type MailProviderConfig,
|
||||
type MailProviderConfigInput,
|
||||
} from "../mail/index.js";
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
@@ -43,32 +51,36 @@ import {
|
||||
type PostgresMigrationDatabaseOptions,
|
||||
planModuleMigrations,
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
createS3StorageProvider,
|
||||
describeStorageProvider,
|
||||
physicalBucketName,
|
||||
type StorageProviderConfig,
|
||||
} from "../storage/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
isConfigurationBlockedDetail,
|
||||
isStorageBlockedDetail,
|
||||
createModuleConfigurationDatabase,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleDatabase,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModuleSchemas,
|
||||
discoverModules,
|
||||
type ModuleEligibility,
|
||||
ModulePermissionCompositionError,
|
||||
type ModuleDiscoveryOptions,
|
||||
isConfigurationBlockedDetail,
|
||||
isStorageBlockedDetail,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleDiscoveryOptions,
|
||||
type ModuleEligibility,
|
||||
type ModuleLifecycleConfiguration,
|
||||
type ModuleMigrationAggregate,
|
||||
ModulePermissionCompositionError,
|
||||
type ModuleSchemaAggregate,
|
||||
resolveModuleEligibility,
|
||||
} from "../modules/index.js";
|
||||
import {
|
||||
createS3StorageProvider,
|
||||
createStorageProviderConfig,
|
||||
describeStorageProvider,
|
||||
physicalBucketName,
|
||||
StorageConfigError,
|
||||
type StorageProviderConfig,
|
||||
type StorageProviderConfigInput,
|
||||
} from "../storage/index.js";
|
||||
|
||||
export type DoctorCheckId =
|
||||
| "database.transport"
|
||||
@@ -85,7 +97,8 @@ export type DoctorCheckId =
|
||||
| "identity.configuration"
|
||||
| "identity.client-address"
|
||||
| "object-storage.integration"
|
||||
| "object-storage.bindings";
|
||||
| "object-storage.bindings"
|
||||
| "outbound-email.configuration";
|
||||
|
||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
|
||||
@@ -104,6 +117,7 @@ export interface DoctorReport {
|
||||
|
||||
export interface DoctorIdentityConfigurationOptions {
|
||||
readonly baseUrl?: string | undefined;
|
||||
readonly dashboardOrigin?: string | undefined;
|
||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||
readonly publicSignupEnabled?: string | undefined;
|
||||
readonly secret?: string | undefined;
|
||||
@@ -120,12 +134,29 @@ export interface DoctorOptions {
|
||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
|
||||
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required
|
||||
* secret as missing, which is the honest answer for a deployment that has not set it. */
|
||||
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports every
|
||||
* required secret as missing, which is only the honest answer for a deployment that has actually
|
||||
* set none, so an executable must pass the same source it would give the runtime. */
|
||||
readonly moduleSecrets?: ModuleSecretSource;
|
||||
/** The validated host storage provider, or `undefined` when this installation has none. Doctor
|
||||
* never receives raw environment values and never prints a credential. */
|
||||
readonly storage?: StorageProviderConfig | undefined;
|
||||
/**
|
||||
* The host's object-storage settings as the executable read them, validated here.
|
||||
*
|
||||
* **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider
|
||||
* is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that
|
||||
* validates it and reports the sanitized code. A pre-validated option meant a bad value threw
|
||||
* before a report existed, and — because the executable then had to validate it itself — meant
|
||||
* `pnpm doctor` simply never passed one at all, reporting configured storage as absent.
|
||||
*/
|
||||
readonly storageConfiguration?: StorageProviderConfigInput | undefined;
|
||||
/**
|
||||
* The host's outbound mail settings as the executable read them, validated here.
|
||||
*
|
||||
* Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a
|
||||
* malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to
|
||||
* be the one that validates it and reports the sanitized code. A pre-validated option would mean
|
||||
* a bad value crashed the command instead of appearing in its report.
|
||||
*/
|
||||
readonly mailConfiguration?: MailProviderConfigInput | undefined;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
}
|
||||
@@ -166,6 +197,9 @@ export interface DoctorDependencies {
|
||||
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
|
||||
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
|
||||
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
|
||||
/** Opens an SMTP connection, authenticates when credentials are configured, and closes —
|
||||
* **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */
|
||||
readonly probeMail: (config: MailProviderConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
const doctorPostgresTimeoutMs = 10_000;
|
||||
@@ -220,6 +254,22 @@ async function probeStorageForRuntime(config: StorageProviderConfig): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind.
|
||||
*
|
||||
* `verify()` performs the SMTP handshake and, where credentials are configured, an authentication
|
||||
* exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every
|
||||
* time an operator ran it would be its own incident.
|
||||
*/
|
||||
async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
|
||||
const provider = createSmtpMailProvider(config);
|
||||
try {
|
||||
await provider.verify();
|
||||
} finally {
|
||||
await provider.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
composePermissions: composePlatformPermissionRegistry,
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
@@ -227,6 +277,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
probeMail: probeMailForRuntime,
|
||||
probeStorage: probeStorageForRuntime,
|
||||
readModuleConfiguration: readModuleConfigurationForRuntime,
|
||||
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||
@@ -266,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
||||
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
|
||||
* — how many of them have a compatible provider among the installed set.
|
||||
*
|
||||
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
|
||||
* capability nobody provides, or whose provider moved outside its declared range, is simply not
|
||||
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
|
||||
* are derived from the same publication registry the API serves from, so this can never disagree
|
||||
* with what the running platform would actually publish.
|
||||
*
|
||||
* Static only, like every other Doctor check: it reports what the *manifests* support and never
|
||||
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
|
||||
*/
|
||||
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||
const publication = createModuleUiPublicationRegistry(modules);
|
||||
const compatible = new Set(
|
||||
publication
|
||||
.listContributions()
|
||||
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||
);
|
||||
return modules.map((module) => {
|
||||
const id = module.manifest.id;
|
||||
const version = module.manifest.version;
|
||||
@@ -276,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
|
||||
) {
|
||||
throw new TypeError("Unsafe module diagnostic value.");
|
||||
}
|
||||
return `${id} ${version}`;
|
||||
const declared = module.contributions.length;
|
||||
if (declared === 0) {
|
||||
return `${id} ${version}`;
|
||||
}
|
||||
const resolved = module.contributions.filter((contribution) =>
|
||||
compatible.has(`${id}\u0000${contribution.id}`),
|
||||
).length;
|
||||
return `${id} ${version} · ${declared} browser contribution${
|
||||
declared === 1 ? "" : "s"
|
||||
} (${resolved} with a compatible provider)`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -678,6 +757,35 @@ function identitySchemaSummary(): string {
|
||||
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045).
|
||||
*
|
||||
* The same hostname on another port is the same site. Otherwise the two hostnames must be siblings:
|
||||
* identical after their first label, with that parent itself holding at least one dot
|
||||
* (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in
|
||||
* both directions for unusual names, which is why its result is a warning and never a failure.
|
||||
*/
|
||||
function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean {
|
||||
const dashboard = new URL(dashboardOrigin).hostname;
|
||||
const api = new URL(apiOrigin).hostname;
|
||||
if (dashboard === api) {
|
||||
return true;
|
||||
}
|
||||
// An IP address is a site of its own; two different ones never share one.
|
||||
const isIpAddress = (hostname: string): boolean =>
|
||||
hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname);
|
||||
if (isIpAddress(dashboard) || isIpAddress(api)) {
|
||||
return false;
|
||||
}
|
||||
const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1);
|
||||
return (
|
||||
dashboard.includes(".") &&
|
||||
api.includes(".") &&
|
||||
parent(dashboard) === parent(api) &&
|
||||
parent(dashboard).includes(".")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
||||
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
||||
@@ -690,6 +798,7 @@ function identityConfigurationCheck(
|
||||
const hasAnyValue =
|
||||
input?.secret !== undefined ||
|
||||
input?.baseUrl !== undefined ||
|
||||
(input?.dashboardOrigin ?? "") !== "" ||
|
||||
input?.publicSignupEnabled !== undefined ||
|
||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||
|
||||
@@ -704,17 +813,43 @@ function identityConfigurationCheck(
|
||||
const config = createIdentityConfig(
|
||||
{
|
||||
baseUrl: input?.baseUrl,
|
||||
// Validated exactly as the API validates it at startup, so a Dashboard origin the API would
|
||||
// refuse (untrusted, insecure, malformed) fails here first.
|
||||
dashboardOrigin: input?.dashboardOrigin,
|
||||
publicSignupEnabled: input?.publicSignupEnabled,
|
||||
secret: input?.secret,
|
||||
trustedOrigins: input?.trustedOrigins,
|
||||
},
|
||||
{ environment: input?.environment ?? "production" },
|
||||
);
|
||||
const signupDetail = config.publicSignupEnabled
|
||||
? "Public signup is explicitly ENABLED."
|
||||
: "Public signup is disabled (safe default).";
|
||||
if (config.dashboardOrigin === config.baseUrl) {
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
signupDetail,
|
||||
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||
]);
|
||||
}
|
||||
const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins).";
|
||||
if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) {
|
||||
return freezeCheck(
|
||||
"identity.configuration",
|
||||
"warning",
|
||||
"Identity configuration valid; split origins may not share a site",
|
||||
[
|
||||
schemaDetail,
|
||||
signupDetail,
|
||||
topologyDetail,
|
||||
"The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.",
|
||||
],
|
||||
);
|
||||
}
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
config.publicSignupEnabled
|
||||
? "Public signup is explicitly ENABLED."
|
||||
: "Public signup is disabled (safe default).",
|
||||
signupDetail,
|
||||
topologyDetail,
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
@@ -770,17 +905,25 @@ function databaseTransportCheck(
|
||||
}
|
||||
}
|
||||
|
||||
/** Doctor reads only its own process's environment, so it says so: run as the reference
|
||||
* deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way
|
||||
* it may be checking a configuration the API is not running. */
|
||||
const CLIENT_ADDRESS_SCOPE =
|
||||
"Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.";
|
||||
|
||||
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||
try {
|
||||
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||
if (config.mode === "direct") {
|
||||
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
||||
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
||||
CLIENT_ADDRESS_SCOPE,
|
||||
]);
|
||||
}
|
||||
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
||||
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
||||
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
||||
CLIENT_ADDRESS_SCOPE,
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
@@ -791,7 +934,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
|
||||
"identity.client-address",
|
||||
"failure",
|
||||
"Client-address configuration invalid",
|
||||
[`[${code}] Configured client-address values failed validation.`],
|
||||
[`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -813,6 +956,23 @@ export async function diagnoseEkklesia(
|
||||
options: DoctorOptions,
|
||||
dependencies: DoctorDependencies = productionDependencies,
|
||||
): Promise<DoctorReport> {
|
||||
// Resolved first, before module eligibility is decided: whether a provider is configured is an
|
||||
// input to whether a module that declares a bucket can run, so validating it later would mean
|
||||
// deciding eligibility against a question that had not been answered yet.
|
||||
const hostEnvironment =
|
||||
options.databaseEnvironment === "development" ? "development" : "production";
|
||||
let storageConfig: StorageProviderConfig | undefined;
|
||||
let storageConfigurationError: string | undefined;
|
||||
try {
|
||||
storageConfig = createStorageProviderConfig(options.storageConfiguration, {
|
||||
environment: hostEnvironment,
|
||||
});
|
||||
} catch (error) {
|
||||
// The sanitized code only — never the endpoint, the access key, or the secret key.
|
||||
storageConfigurationError =
|
||||
error instanceof StorageConfigError ? error.code : "STORAGE_CONFIGURATION_INVALID";
|
||||
}
|
||||
|
||||
let runtimeCheck: DoctorCheck;
|
||||
try {
|
||||
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
|
||||
@@ -922,7 +1082,7 @@ export async function diagnoseEkklesia(
|
||||
discoveredModules,
|
||||
configuration,
|
||||
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
|
||||
{ providerConfigured: options.storage !== undefined },
|
||||
{ providerConfigured: storageConfig !== undefined },
|
||||
);
|
||||
eligibilityEntries = resolution.entries;
|
||||
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
|
||||
@@ -1037,7 +1197,20 @@ export async function diagnoseEkklesia(
|
||||
// whether modules run. Conflating them would let a momentary network blip read as a module
|
||||
// problem (ADR 0030).
|
||||
let storageCheck: DoctorCheck;
|
||||
if (!options.storage) {
|
||||
if (storageConfigurationError !== undefined) {
|
||||
// A failure, unlike an unreachable provider: this is not a network condition an operator can
|
||||
// wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is
|
||||
// genuinely not ready, and saying so is what stops the operator from discovering it at boot.
|
||||
storageCheck = freezeCheck(
|
||||
"object-storage.integration",
|
||||
"failure",
|
||||
"Object storage configuration invalid",
|
||||
[
|
||||
`The object-storage settings were rejected: ${storageConfigurationError}.`,
|
||||
"Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.",
|
||||
],
|
||||
);
|
||||
} else if (!storageConfig) {
|
||||
storageCheck = freezeCheck(
|
||||
"object-storage.integration",
|
||||
"skipped",
|
||||
@@ -1046,14 +1219,14 @@ export async function diagnoseEkklesia(
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await dependencies.probeStorage(options.storage);
|
||||
await dependencies.probeStorage(storageConfig);
|
||||
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
|
||||
describeStorageProvider(options.storage),
|
||||
describeStorageProvider(storageConfig),
|
||||
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a
|
||||
// separate browser-facing origin is by definition something a browser reaches and this
|
||||
// process may not. Reporting "reachable" without that caveat would let an operator conclude
|
||||
// direct delivery works when only half of it was tested.
|
||||
...(options.storage.deliveryEndpoint === options.storage.endpoint
|
||||
...(storageConfig.deliveryEndpoint === storageConfig.endpoint
|
||||
? []
|
||||
: [
|
||||
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
|
||||
@@ -1068,7 +1241,7 @@ export async function diagnoseEkklesia(
|
||||
"warning",
|
||||
"Object storage unreachable",
|
||||
[
|
||||
describeStorageProvider(options.storage),
|
||||
describeStorageProvider(storageConfig),
|
||||
"The configured storage service did not respond. Modules already running are unaffected.",
|
||||
],
|
||||
);
|
||||
@@ -1076,11 +1249,74 @@ export async function diagnoseEkklesia(
|
||||
}
|
||||
|
||||
const storageBindings = discoveredModules
|
||||
? storageBindingsCheck(discoveredModules, eligibilityEntries, options.storage)
|
||||
? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig)
|
||||
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
|
||||
// Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not
|
||||
// answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in
|
||||
// the platform sends email yet, so an installation without SMTP is complete rather than degraded
|
||||
// (ADR 0037).
|
||||
let mailCheck: DoctorCheck;
|
||||
let mailConfig: MailProviderConfig | undefined;
|
||||
let mailConfigurationError: string | undefined;
|
||||
try {
|
||||
mailConfig = createMailProviderConfig(options.mailConfiguration, {
|
||||
environment: hostEnvironment,
|
||||
});
|
||||
} catch (error) {
|
||||
// The sanitized code only — never the host, the username, or the password.
|
||||
mailConfigurationError =
|
||||
error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID";
|
||||
}
|
||||
|
||||
if (mailConfigurationError !== undefined) {
|
||||
mailCheck = freezeCheck(
|
||||
"outbound-email.configuration",
|
||||
"failure",
|
||||
"Outbound email configuration invalid",
|
||||
[
|
||||
`The SMTP settings were rejected: ${mailConfigurationError}.`,
|
||||
"Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.",
|
||||
],
|
||||
);
|
||||
} else if (!mailConfig) {
|
||||
mailCheck = freezeCheck(
|
||||
"outbound-email.configuration",
|
||||
"skipped",
|
||||
"Outbound email not configured",
|
||||
[
|
||||
"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 {
|
||||
try {
|
||||
await dependencies.probeMail(mailConfig);
|
||||
mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [
|
||||
describeMailProvider(mailConfig),
|
||||
// Said plainly: this connected and, where credentials are set, authenticated. It did not
|
||||
// send anything, so it proves the relay accepts this deployment — not that a message would
|
||||
// reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization
|
||||
// policy and cannot be established from here.
|
||||
"Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.",
|
||||
]);
|
||||
} catch {
|
||||
// A warning, never a failure, exactly as for storage: the configuration is valid and nothing
|
||||
// in the platform depends on mail yet, so a relay that is briefly unreachable — or one that
|
||||
// declines a bare connectivity check — must not make the deployment read as broken.
|
||||
mailCheck = freezeCheck(
|
||||
"outbound-email.configuration",
|
||||
"warning",
|
||||
"Outbound email unreachable",
|
||||
[
|
||||
describeMailProvider(mailConfig),
|
||||
"The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return publishReport([
|
||||
runtimeCheck,
|
||||
moduleCheck,
|
||||
@@ -1097,5 +1333,6 @@ export async function diagnoseEkklesia(
|
||||
clientAddressDoctorCheck,
|
||||
storageCheck,
|
||||
storageBindings,
|
||||
mailCheck,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { ModuleSecretSource } from "@ekklesia/contracts";
|
||||
import type { DatabaseTransportPolicyInput } from "../database/index.js";
|
||||
import type { ClientAddressConfigInput } from "../identity/index.js";
|
||||
import type { MailProviderConfigInput } from "../mail/index.js";
|
||||
import { createEnvironmentModuleSecretSource } from "../modules/index.js";
|
||||
import type { StorageProviderConfigInput } from "../storage/index.js";
|
||||
|
||||
/**
|
||||
* A deployment's environment, passed **as an argument**.
|
||||
*
|
||||
* `process.env` satisfies this, but nothing here reads it: the repository rule that only
|
||||
* executables touch the process environment is preserved exactly as
|
||||
* `createEnvironmentModuleSecretSource` preserves it, and it is what makes every variable this
|
||||
* module understands testable without mutating global state.
|
||||
*/
|
||||
export type HostEnvironment = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export type HostEnvironmentName = "development" | "production";
|
||||
|
||||
/**
|
||||
* The identity settings as they appear in the environment — strings, unvalidated.
|
||||
*
|
||||
* Typed as `string | undefined` rather than `unknown` so this one object can be handed to both
|
||||
* `createIdentityConfig` (which validates it) and Doctor's `identityConfiguration` (which validates
|
||||
* it and reports the sanitized code) without either side re-deriving it.
|
||||
*/
|
||||
export interface HostIdentityEnvironmentInput {
|
||||
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 secret: string | undefined;
|
||||
readonly trustedOrigins: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every host infrastructure input an Ekklesia executable resolves from its environment.
|
||||
*
|
||||
* Deliberately **unvalidated**: each consumer runs the same Core validator over these values, but
|
||||
* they do so at different moments and want different failure behaviour. `startApi` wants a
|
||||
* malformed value to abort startup; Doctor wants it reported as a sanitized code in a check. If this
|
||||
* type were pre-validated, Doctor could never report a configuration error — the load would have
|
||||
* thrown before a report existed.
|
||||
*
|
||||
* The API listener's own `host`/`port` are deliberately absent: they describe the API process, not
|
||||
* the host infrastructure a diagnostic evaluates, and Doctor has no use for them.
|
||||
*/
|
||||
export interface HostConfiguration {
|
||||
/** `"development"` only when `NODE_ENV` is exactly that string, so an unset or misspelled value
|
||||
* validates as production and never silently relaxes a security rule. */
|
||||
readonly environment: HostEnvironmentName;
|
||||
/** `undefined` when no mode is set at all, which selects direct mode. */
|
||||
readonly clientAddress: ClientAddressConfigInput | undefined;
|
||||
readonly databaseTransport: DatabaseTransportPolicyInput;
|
||||
readonly databaseUrl: string | undefined;
|
||||
readonly identity: HostIdentityEnvironmentInput;
|
||||
readonly mail: MailProviderConfigInput;
|
||||
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
|
||||
* declared keys — there is no arbitrary variable lookup. */
|
||||
readonly moduleSecrets: ModuleSecretSource;
|
||||
readonly storage: StorageProviderConfigInput;
|
||||
}
|
||||
|
||||
function splitList(value: string | undefined): readonly string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
/** Omitted entirely when no mode is set: reading `header`/`trustedProxies` only when `mode` is
|
||||
* actually present keeps an operator from half-configuring proxy mode by setting one of three
|
||||
* variables. */
|
||||
function readClientAddress(environment: HostEnvironment): ClientAddressConfigInput | undefined {
|
||||
const mode = environment.EKKLESIA_CLIENT_ADDRESS_MODE;
|
||||
if (mode === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
header: environment.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
||||
mode,
|
||||
trustedProxies: splitList(environment.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place any Ekklesia executable turns a deployment environment into host infrastructure
|
||||
* configuration.
|
||||
*
|
||||
* It exists because the alternative was demonstrably wrong: `apps/api` and `pnpm doctor` each read
|
||||
* their own subset of the same variables, and the subsets drifted. Doctor never read `S3_*` at all,
|
||||
* so a correctly configured installation was told its object storage was "not configured" and every
|
||||
* module requiring a bucket was reported blocked — while `startApi`, from the identical environment,
|
||||
* bound those buckets and ran the modules. A diagnostic that disagrees with the runtime it
|
||||
* diagnoses is worse than no diagnostic.
|
||||
*
|
||||
* So there is now exactly one mapping from variable names to typed inputs, and both executables call
|
||||
* it. Validation stays where it already lives — `createIdentityConfig`,
|
||||
* `createDatabaseTransportPolicy`, `createStorageProviderConfig`, `createMailProviderConfig` — and
|
||||
* is deliberately *not* performed here, so each caller keeps its own failure behaviour.
|
||||
*
|
||||
* Nothing is read from `process.env`, and nothing here logs, prints, or serializes: the returned
|
||||
* object carries the identity secret, both storage credentials, and the SMTP password.
|
||||
*/
|
||||
export function loadHostConfiguration(environment: HostEnvironment): HostConfiguration {
|
||||
return Object.freeze({
|
||||
clientAddress: readClientAddress(environment),
|
||||
databaseTransport: {
|
||||
...(environment.EKKLESIA_DATABASE_CA === undefined
|
||||
? {}
|
||||
: { certificateAuthority: environment.EKKLESIA_DATABASE_CA }),
|
||||
...(environment.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||
? {}
|
||||
: { mode: environment.EKKLESIA_DATABASE_TLS_MODE }),
|
||||
},
|
||||
databaseUrl: environment.DATABASE_URL,
|
||||
environment: environment.NODE_ENV === "development" ? "development" : "production",
|
||||
identity: {
|
||||
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
|
||||
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
|
||||
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
||||
secret: environment.EKKLESIA_IDENTITY_SECRET,
|
||||
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
|
||||
},
|
||||
// Names follow the platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a
|
||||
// provider's. Absent host means this installation sends no outbound email, which is a complete
|
||||
// deployment (ADR 0037); username and password are optional as a pair, because a relay
|
||||
// authorized by source IP has neither.
|
||||
mail: {
|
||||
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
|
||||
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,
|
||||
password: environment.EKKLESIA_SMTP_PASSWORD,
|
||||
port: environment.EKKLESIA_SMTP_PORT,
|
||||
security: environment.EKKLESIA_SMTP_SECURITY,
|
||||
username: environment.EKKLESIA_SMTP_USERNAME,
|
||||
},
|
||||
moduleSecrets: createEnvironmentModuleSecretSource(environment),
|
||||
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
|
||||
// endpoint means this installation has no object storage, which is a complete deployment
|
||||
// (ADR 0030).
|
||||
storage: {
|
||||
accessKey: environment.S3_ACCESS_KEY,
|
||||
bucketPrefix: environment.S3_BUCKET_PREFIX,
|
||||
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
|
||||
// storage by is not a name a browser could resolve.
|
||||
deliveryEndpoint: environment.S3_DELIVERY_ENDPOINT,
|
||||
endpoint: environment.S3_ENDPOINT,
|
||||
forcePathStyle: environment.S3_FORCE_PATH_STYLE,
|
||||
region: environment.S3_REGION,
|
||||
secretKey: environment.S3_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type HostConfiguration,
|
||||
type HostEnvironment,
|
||||
type HostEnvironmentName,
|
||||
type HostIdentityEnvironmentInput,
|
||||
loadHostConfiguration,
|
||||
} from "./configuration.js";
|
||||
@@ -0,0 +1,132 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import { identityAdapterSchema } from "./schema.js";
|
||||
|
||||
/**
|
||||
* Creates a first credential identity **through Better Auth's own sign-up path**, for the
|
||||
* operator-invoked initial-owner bootstrap and nothing else.
|
||||
*
|
||||
* ## Why this is not `createIdentityRuntime`
|
||||
*
|
||||
* The server runtime deliberately refuses to create accounts: `disableSignUp` is derived from
|
||||
* `IdentityConfig.publicSignupEnabled`, which production cannot enable at all
|
||||
* (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). That is correct and is not relaxed here — calling
|
||||
* `auth.api.signUpEmail` on the server's own instance is refused with
|
||||
* `EMAIL_PASSWORD_SIGN_UP_DISABLED`, which was verified against the pinned release rather than
|
||||
* assumed. So a fresh production installation has no way to create the one identity it needs.
|
||||
*
|
||||
* This is that way, and its safety comes from *where it can be invoked*, not from a weaker policy:
|
||||
* it is an in-process library call reachable only by an operator who already holds the database
|
||||
* credentials, it serves no HTTP route, and it is mounted nowhere. Nothing in `apps/api` imports
|
||||
* it. Public signup remains disabled in every environment.
|
||||
*
|
||||
* ## Why the library path rather than writing rows
|
||||
*
|
||||
* Hashing, salting, email normalization, the `user`/`account` pair, and the 8–128 password bounds
|
||||
* are all Better Auth's, applied exactly as they are for a browser sign-up. Hand-writing a user row
|
||||
* and a password hash would be a second, divergent identity implementation — and the failure mode
|
||||
* is an account that exists but cannot sign in.
|
||||
*
|
||||
* ## Prerequisites this deliberately does not have
|
||||
*
|
||||
* No `baseURL`, no trusted origins, and no configured identity secret. All three were tested
|
||||
* against the pinned release: sign-up succeeds without them, and the resulting account signs in
|
||||
* through a *separately constructed* instance carrying a different secret — password hashes are
|
||||
* salted per account and independent of the secret. That matters operationally, because it means an
|
||||
* operator can create the first owner on a freshly provisioned server before DNS, TLS, or the
|
||||
* public origin exist. The ephemeral secret below exists only so the instance is explicitly
|
||||
* configured rather than silently defaulted; this instance never mints a session, so nothing is
|
||||
* ever signed with it and it is discarded when the process exits.
|
||||
*/
|
||||
export type BootstrapIdentityErrorCode =
|
||||
| "BOOTSTRAP_IDENTITY_CREATION_FAILED"
|
||||
| "BOOTSTRAP_IDENTITY_EMAIL_INVALID"
|
||||
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG"
|
||||
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT";
|
||||
|
||||
const bootstrapIdentityMessages: Readonly<Record<BootstrapIdentityErrorCode, string>> =
|
||||
Object.freeze({
|
||||
BOOTSTRAP_IDENTITY_CREATION_FAILED: "The initial identity could not be created.",
|
||||
BOOTSTRAP_IDENTITY_EMAIL_INVALID: "The email address is not a valid address.",
|
||||
BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||
BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT:
|
||||
"The password is shorter than the identity system allows.",
|
||||
});
|
||||
|
||||
/** Never carries the password, the email, a hash, a Better Auth response body, or a cause. */
|
||||
export class BootstrapIdentityError extends Error {
|
||||
readonly code: BootstrapIdentityErrorCode;
|
||||
|
||||
constructor(code: BootstrapIdentityErrorCode) {
|
||||
super(bootstrapIdentityMessages[code]);
|
||||
this.name = "BootstrapIdentityError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapIdentityInput {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/** Creates one credential identity. Resolving says only that the identity system reported success —
|
||||
* never that a row exists, which the caller must confirm for itself (see `createInitialPlatformOwner`). */
|
||||
export type BootstrapIdentityCreator = (input: BootstrapIdentityInput) => Promise<void>;
|
||||
|
||||
/** Better Auth reports failures as an `APIError` carrying a `body.code`. Only the codes that are
|
||||
* genuinely the operator's to act on are distinguished; everything else collapses into the generic
|
||||
* creation failure so no library internal, response body, or cause is ever surfaced. */
|
||||
function translateBootstrapIdentityError(error: unknown): BootstrapIdentityError {
|
||||
const code = (error as { body?: { code?: unknown } } | null)?.body?.code;
|
||||
if (code === "PASSWORD_TOO_SHORT") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT");
|
||||
}
|
||||
if (code === "PASSWORD_TOO_LONG") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG");
|
||||
}
|
||||
if (code === "VALIDATION_ERROR") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_EMAIL_INVALID");
|
||||
}
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_CREATION_FAILED");
|
||||
}
|
||||
|
||||
export function createBootstrapIdentityCreator(
|
||||
db: IdentityDrizzleDatabase,
|
||||
): BootstrapIdentityCreator {
|
||||
const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg", schema: identityAdapterSchema }),
|
||||
emailAndPassword: {
|
||||
// No session is minted here. The operator signs in through the ordinary browser transport
|
||||
// afterwards, which is also what proves the account really works.
|
||||
autoSignIn: false,
|
||||
// Enabled on *this* instance only. See the note above: this object serves no HTTP route.
|
||||
disableSignUp: false,
|
||||
enabled: true,
|
||||
// The identical bounds `createIdentityRuntime` pins, so the CLI can never accept a password
|
||||
// the browser would later reject, or reject one it would accept.
|
||||
maxPasswordLength: 128,
|
||||
minPasswordLength: 8,
|
||||
},
|
||||
logger: {
|
||||
// Better Auth's own log call sites interpolate raw input and exceptions; during account
|
||||
// creation that input includes a password. Nothing it wants to log is ever emitted.
|
||||
disabled: true,
|
||||
},
|
||||
secret: randomBytes(32).toString("base64url"),
|
||||
});
|
||||
|
||||
return async (input: BootstrapIdentityInput): Promise<void> => {
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: input.email, name: input.name, password: input.password },
|
||||
});
|
||||
} catch (error) {
|
||||
throw translateBootstrapIdentityError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
|
||||
| "BASE_URL_INVALID"
|
||||
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
||||
| "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_UNSUPPORTED_IN_PRODUCTION"
|
||||
| "SECRET_LOW_ENTROPY"
|
||||
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
|
||||
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
||||
"The identity base URL must not resolve to a loopback host in production.",
|
||||
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 enablement must be an explicit boolean or the exact string "true"/"false".',
|
||||
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
||||
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
|
||||
|
||||
export interface IdentityConfig {
|
||||
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) —
|
||||
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
||||
* loosely parsed. Development and test composition may enable it explicitly; production
|
||||
@@ -176,6 +202,8 @@ export interface IdentityConfig {
|
||||
|
||||
export interface IdentityConfigInput {
|
||||
readonly baseUrl?: unknown;
|
||||
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
|
||||
readonly dashboardOrigin?: unknown;
|
||||
readonly publicSignupEnabled?: unknown;
|
||||
readonly secret?: unknown;
|
||||
readonly trustedOrigins?: unknown;
|
||||
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
|
||||
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 {
|
||||
if (typeof origin !== "string" || origin.length === 0) {
|
||||
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
||||
@@ -355,7 +422,19 @@ export function createIdentityConfig(
|
||||
const secret = validateSecret(input.secret);
|
||||
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
||||
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
||||
const dashboardOrigin = validateDashboardOrigin(
|
||||
input.dashboardOrigin,
|
||||
baseUrl,
|
||||
trustedOrigins,
|
||||
environment,
|
||||
);
|
||||
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
||||
|
||||
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
|
||||
return Object.freeze({
|
||||
baseUrl,
|
||||
dashboardOrigin,
|
||||
publicSignupEnabled,
|
||||
secret,
|
||||
trustedOrigins,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./bootstrap-identity.js";
|
||||
export * from "./client-address.js";
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
export * from "./notifications.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 type { IdentityConfig } from "./config.js";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import type { IdentityNotifications } from "./notifications.js";
|
||||
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
||||
|
||||
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.";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 db: IdentityDrizzleDatabase;
|
||||
/**
|
||||
* A narrow, explicit override for the sign-in/sign-up rate-limit window 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 strict built-in default
|
||||
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified.
|
||||
* How verification and password-reset messages are delivered.
|
||||
*
|
||||
* Injected rather than constructed here, for the same reason the database is: this file composes
|
||||
* 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;
|
||||
}
|
||||
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
|
||||
enabled: true,
|
||||
// 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
|
||||
// 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,
|
||||
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: {
|
||||
disabled: false,
|
||||
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
|
||||
...(rateLimitOverride
|
||||
? {
|
||||
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": {
|
||||
max: rateLimitOverride.max ?? 100,
|
||||
window: rateLimitOverride.window ?? 10,
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
isValidMailAddress,
|
||||
isValidMailDisplayName,
|
||||
MAIL_MAX_DISPLAY_NAME_LENGTH,
|
||||
} from "./message.js";
|
||||
|
||||
export type MailConfigErrorCode =
|
||||
| "MAIL_FROM_ADDRESS_INVALID"
|
||||
| "MAIL_FROM_ADDRESS_MISSING"
|
||||
| "MAIL_FROM_NAME_INVALID"
|
||||
| "MAIL_HELO_NAME_INVALID"
|
||||
| "MAIL_HOST_INVALID"
|
||||
| "MAIL_PASSWORD_WITHOUT_USERNAME"
|
||||
| "MAIL_PORT_INVALID"
|
||||
| "MAIL_SECURITY_INVALID"
|
||||
| "MAIL_SECURITY_INSECURE_IN_PRODUCTION"
|
||||
| "MAIL_USERNAME_WITHOUT_PASSWORD";
|
||||
|
||||
const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object.freeze({
|
||||
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_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_PASSWORD_WITHOUT_USERNAME:
|
||||
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
|
||||
MAIL_PORT_INVALID: "The SMTP port must be an integer between 1 and 65535.",
|
||||
MAIL_SECURITY_INSECURE_IN_PRODUCTION:
|
||||
"A production SMTP transport must use implicit TLS or STARTTLS; plaintext is development-only.",
|
||||
MAIL_SECURITY_INVALID:
|
||||
'The SMTP security mode must be exactly "implicit-tls", "starttls", or "plaintext".',
|
||||
MAIL_USERNAME_WITHOUT_PASSWORD:
|
||||
"An SMTP username requires a password; supply both or neither for an unauthenticated relay.",
|
||||
});
|
||||
|
||||
/**
|
||||
* Never carries the host, port, username, password, or sender — only a fixed code and message.
|
||||
* Matching `StorageConfigError` exactly, and for the same sharper reason: one of the values this
|
||||
* validates is a credential.
|
||||
*/
|
||||
export class MailConfigError extends Error {
|
||||
readonly code: MailConfigErrorCode;
|
||||
|
||||
constructor(code: MailConfigErrorCode) {
|
||||
super(mailConfigMessages[code]);
|
||||
this.name = "MailConfigError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export type MailEnvironment = "development" | "production";
|
||||
|
||||
/**
|
||||
* How the connection is protected, stated explicitly rather than inferred.
|
||||
*
|
||||
* A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear
|
||||
* and hope STARTTLS is offered", and a server that quietly stops advertising STARTTLS — or an
|
||||
* attacker who strips it — turns that into a plaintext session carrying a password, with nothing in
|
||||
* the configuration that changed. Naming the mode makes the downgrade impossible to reach by
|
||||
* accident: `starttls` **requires** the upgrade and fails the send if it is unavailable.
|
||||
*
|
||||
* - `implicit-tls` — TLS from the first byte, conventionally port 465.
|
||||
* - `starttls` — plaintext connect, then a *mandatory* upgrade, conventionally 587 (or 25 on a
|
||||
* relay). This is what Google Workspace SMTP Relay and most modern providers expect.
|
||||
* - `plaintext` — no TLS at all. Development and a trusted local relay only; production refuses it.
|
||||
*/
|
||||
export const MAIL_SECURITY_MODES = ["implicit-tls", "starttls", "plaintext"] as const;
|
||||
|
||||
export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
|
||||
|
||||
export interface MailProviderConfigInput {
|
||||
readonly host?: string | undefined;
|
||||
readonly heloName?: string | undefined;
|
||||
readonly port?: string | number | undefined;
|
||||
readonly security?: string | undefined;
|
||||
readonly username?: string | undefined;
|
||||
readonly password?: string | undefined;
|
||||
readonly fromAddress?: string | undefined;
|
||||
readonly fromName?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The validated host mail configuration.
|
||||
*
|
||||
* **No feature or module ever sees this.** It reaches Core's provider and nothing else; the
|
||||
* `MailProvider` seam speaks only in validated messages, and there is no accessor anywhere that
|
||||
* could return the host, the port, or the password (ADR 0037).
|
||||
*/
|
||||
export interface MailProviderConfig {
|
||||
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 security: MailSecurityMode;
|
||||
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
|
||||
* Workspace arrangement — has neither. */
|
||||
readonly username?: string;
|
||||
readonly password?: string;
|
||||
readonly fromAddress: string;
|
||||
readonly fromName?: string;
|
||||
}
|
||||
|
||||
/** Conventional defaults per mode, so an operator who names a mode does not also have to remember a
|
||||
* port number. Any port may still be set explicitly. */
|
||||
const DEFAULT_PORTS: Readonly<Record<MailSecurityMode, number>> = Object.freeze({
|
||||
"implicit-tls": 465,
|
||||
plaintext: 25,
|
||||
starttls: 587,
|
||||
});
|
||||
|
||||
/** A bare hostname or IP literal: no scheme, no port, no path, no credentials. Anything richer is a
|
||||
* connection string, and a connection string is where a password ends up in a log. */
|
||||
const HOST_PATTERN =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/iu;
|
||||
const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/u;
|
||||
|
||||
function validateHost(host: string): string {
|
||||
const trimmed = host.trim();
|
||||
if (trimmed.length === 0 || (!HOST_PATTERN.test(trimmed) && !IPV4_PATTERN.test(trimmed))) {
|
||||
throw new MailConfigError("MAIL_HOST_INVALID");
|
||||
}
|
||||
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 {
|
||||
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
|
||||
return DEFAULT_PORTS[security];
|
||||
}
|
||||
const parsed = typeof port === "number" ? port : Number(port.trim());
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new MailConfigError("MAIL_PORT_INVALID");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateSecurity(
|
||||
security: string | undefined,
|
||||
environment: MailEnvironment,
|
||||
): MailSecurityMode {
|
||||
// Defaulting to STARTTLS rather than to whatever the port implies: an omitted mode must never be
|
||||
// the one that sends a password in the clear.
|
||||
const mode = security === undefined || security.trim() === "" ? "starttls" : security.trim();
|
||||
if (!(MAIL_SECURITY_MODES as readonly string[]).includes(mode)) {
|
||||
throw new MailConfigError("MAIL_SECURITY_INVALID");
|
||||
}
|
||||
if (environment === "production" && mode === "plaintext") {
|
||||
throw new MailConfigError("MAIL_SECURITY_INSECURE_IN_PRODUCTION");
|
||||
}
|
||||
return mode as MailSecurityMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the host's outbound email configuration, or reports that this installation has none.
|
||||
*
|
||||
* **Outbound email is optional.** An installation with no `host` returns `undefined`, and that is a
|
||||
* complete, valid deployment — the platform starts, every existing feature works, and only a future
|
||||
* feature that actually needs to send is affected, which it discovers as a structured
|
||||
* `MAIL_NOT_CONFIGURED` rather than a crash (ADR 0037).
|
||||
*
|
||||
* Credentials are validated as a **pair**. Many SMTP examples assume a username and password, but a
|
||||
* relay authorized by source IP — which is how Google Workspace SMTP Relay is commonly deployed —
|
||||
* has neither, so requiring them would make the intended production arrangement unconfigurable.
|
||||
* Half a pair is always a mistake, though: it means someone set one variable and expected
|
||||
* authentication, so it fails loudly instead of silently connecting anonymously.
|
||||
*/
|
||||
export function createMailProviderConfig(
|
||||
input: MailProviderConfigInput | undefined,
|
||||
options: { readonly environment?: MailEnvironment } = {},
|
||||
): MailProviderConfig | undefined {
|
||||
const host = input?.host?.trim();
|
||||
if (host === undefined || host === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const environment = options.environment ?? "production";
|
||||
const security = validateSecurity(input?.security, environment);
|
||||
const validatedHost = validateHost(host);
|
||||
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 password = input?.password === "" ? undefined : input?.password;
|
||||
if (username !== undefined && password === undefined) {
|
||||
throw new MailConfigError("MAIL_USERNAME_WITHOUT_PASSWORD");
|
||||
}
|
||||
if (password !== undefined && username === undefined) {
|
||||
throw new MailConfigError("MAIL_PASSWORD_WITHOUT_USERNAME");
|
||||
}
|
||||
|
||||
const fromAddress = input?.fromAddress?.trim();
|
||||
if (fromAddress === undefined || fromAddress === "") {
|
||||
throw new MailConfigError("MAIL_FROM_ADDRESS_MISSING");
|
||||
}
|
||||
if (!isValidMailAddress(fromAddress)) {
|
||||
throw new MailConfigError("MAIL_FROM_ADDRESS_INVALID");
|
||||
}
|
||||
|
||||
const fromName = input?.fromName?.trim() === "" ? undefined : input?.fromName?.trim();
|
||||
if (fromName !== undefined && !isValidMailDisplayName(fromName)) {
|
||||
throw new MailConfigError("MAIL_FROM_NAME_INVALID");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
fromAddress,
|
||||
host: validatedHost,
|
||||
port,
|
||||
security,
|
||||
...(heloName === undefined ? {} : { heloName }),
|
||||
...(fromName === undefined ? {} : { fromName }),
|
||||
...(username === undefined || password === undefined ? {} : { password, username }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-line operator-facing description that is safe to print anywhere — Doctor output, a log line,
|
||||
* a support transcript.
|
||||
*
|
||||
* It names the host, port, security mode, and sender, and states **whether** authentication is
|
||||
* configured without ever naming the username or password. `describeStorageProvider` sets the same
|
||||
* precedent.
|
||||
*/
|
||||
export function describeMailProvider(config: MailProviderConfig): string {
|
||||
const sender =
|
||||
config.fromName === undefined
|
||||
? 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} · ${
|
||||
config.username === undefined ? "unauthenticated relay" : "authenticated"
|
||||
}${helo}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./config.js";
|
||||
export * from "./message.js";
|
||||
export * from "./provider.js";
|
||||
export * from "./smtp-provider.js";
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* The one transactional message shape Ekklesia sends, and the boundary that validates it.
|
||||
*
|
||||
* **There is deliberately no `from` field.** The sender is host configuration
|
||||
* (`MailProviderConfig.fromAddress`), so no caller — not Core, not a future module — can choose who
|
||||
* a message appears to come from. Letting feature code pick a sender is how a platform acquires an
|
||||
* impersonation primitive and destroys its own domain reputation at the same time (ADR 0037).
|
||||
*
|
||||
* Also deliberately absent: CC, BCC, attachments, custom headers, and templates. Identity
|
||||
* verification, password reset, and invitations each need exactly one recipient and a short body.
|
||||
* Every field beyond that is surface a future caller could abuse, so it stays out until something
|
||||
* real needs it.
|
||||
*/
|
||||
|
||||
export type MailErrorCode =
|
||||
| "MAIL_MESSAGE_INVALID"
|
||||
| "MAIL_NOT_CONFIGURED"
|
||||
| "MAIL_PROVIDER_UNAVAILABLE"
|
||||
| "MAIL_RECIPIENT_INVALID"
|
||||
| "MAIL_REJECTED"
|
||||
| "MAIL_SEND_FAILED";
|
||||
|
||||
const mailErrorMessages: Readonly<Record<MailErrorCode, string>> = Object.freeze({
|
||||
MAIL_MESSAGE_INVALID: "The message is not one this contract accepts.",
|
||||
MAIL_NOT_CONFIGURED: "This installation has no outbound email provider configured.",
|
||||
MAIL_PROVIDER_UNAVAILABLE: "The outbound email service could not be reached.",
|
||||
MAIL_RECIPIENT_INVALID: "The recipient address is not a valid email address.",
|
||||
MAIL_REJECTED: "The outbound email service refused the message.",
|
||||
MAIL_SEND_FAILED: "The message could not be sent.",
|
||||
});
|
||||
|
||||
/**
|
||||
* A sanitized mail failure.
|
||||
*
|
||||
* Carries a code and a fixed message and **nothing else** — no SMTP host, no port, no username, no
|
||||
* password, no server greeting, no auth challenge, no provider response text, and no caught cause.
|
||||
* SMTP servers routinely echo the envelope and sometimes the credential state back in their reply
|
||||
* text, so forwarding a provider error verbatim is how a password reaches a log. It follows the same
|
||||
* discipline as `ModuleStorageError` and `StorageConfigError`.
|
||||
*/
|
||||
export class MailError extends Error {
|
||||
readonly code: MailErrorCode;
|
||||
|
||||
constructor(code: MailErrorCode) {
|
||||
super(mailErrorMessages[code]);
|
||||
this.name = "MailError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds, chosen to be generous for a transactional message and far too small for a newsletter.
|
||||
*
|
||||
* A verification email is a couple of kilobytes. These leave two orders of magnitude of headroom
|
||||
* while making it structurally impossible to hand the provider a megabyte, or to turn one call into
|
||||
* a mailing list.
|
||||
*/
|
||||
export const MAIL_MAX_RECIPIENTS = 10;
|
||||
export const MAIL_MAX_SUBJECT_LENGTH = 200;
|
||||
export const MAIL_MAX_TEXT_BYTES = 64 * 1024;
|
||||
export const MAIL_MAX_HTML_BYTES = 256 * 1024;
|
||||
/** RFC 5321 caps a path at 256 octets; the local part and domain limits sit inside that. */
|
||||
export const MAIL_MAX_ADDRESS_LENGTH = 254;
|
||||
export const MAIL_MAX_DISPLAY_NAME_LENGTH = 128;
|
||||
|
||||
export interface MailMessage {
|
||||
/** One address, or a small bounded list. */
|
||||
readonly to: string | readonly string[];
|
||||
readonly subject: string;
|
||||
/** At least one of `text` or `html` is required — see `validateMailMessage`. */
|
||||
readonly text?: string;
|
||||
readonly html?: string;
|
||||
/** Where a human reply should go, when that is not the host's unattended sender. */
|
||||
readonly replyTo?: string;
|
||||
}
|
||||
|
||||
/** The message after validation: recipients normalized to a list, and every field proven safe to
|
||||
* hand a provider. A provider receives only this, never the caller's raw input. */
|
||||
export interface ValidatedMailMessage {
|
||||
readonly to: readonly string[];
|
||||
readonly subject: string;
|
||||
readonly text?: string;
|
||||
readonly html?: string;
|
||||
readonly replyTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a string is free of everything that could break out of the field it is placed in.
|
||||
*
|
||||
* A mail header is line-oriented, so a carriage return or line feed in a subject, an address, or a
|
||||
* display name lets a caller append headers of its own — a `Bcc:` to somewhere else, a replaced
|
||||
* `From:`, or an entire second message body. This is the classic email header injection, and it is
|
||||
* why these characters are **refused** rather than stripped: silently rewriting a caller's input
|
||||
* means the value that was validated is not the value that gets sent, and the two can drift.
|
||||
*
|
||||
* Every other C0 control, DEL, and the C1 range go with them. None has a legitimate place in a
|
||||
* transactional subject line, and several are interpreted by intermediaries.
|
||||
*/
|
||||
function hasUnsafeCharacters(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
if (codePoint === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deliberately conservative address check.
|
||||
*
|
||||
* This is not an RFC 5322 parser and does not try to be: the point is to reject what is dangerous or
|
||||
* obviously wrong before it reaches the provider, and to let the provider be authoritative about
|
||||
* exotic-but-legal mailboxes. What it does guarantee is that the value is a single, bare
|
||||
* `local@domain` with no display name, no angle brackets, no comma, no semicolon, and no control
|
||||
* character — so it cannot smuggle a second recipient or a header break through a field the caller
|
||||
* believes holds one address.
|
||||
*/
|
||||
export function isValidMailAddress(value: unknown): value is string {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
// Checked **before** trimming, deliberately. Trimming first would silently strip a trailing
|
||||
// carriage return and accept the address, which contradicts the rule above: a control character in
|
||||
// an address is refused, never repaired. Surrounding ordinary whitespace is still tolerated,
|
||||
// because that is a copy-paste artefact rather than an injection attempt.
|
||||
if (hasUnsafeCharacters(value)) {
|
||||
return false;
|
||||
}
|
||||
const address = value.trim();
|
||||
if (address.length === 0 || address.length > MAIL_MAX_ADDRESS_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
// No grouping or list punctuation: one address means one address.
|
||||
if (/[,;<>"\\()[\]\s]/u.test(address)) {
|
||||
return false;
|
||||
}
|
||||
const at = address.lastIndexOf("@");
|
||||
if (at <= 0 || at === address.length - 1) {
|
||||
return false;
|
||||
}
|
||||
const local = address.slice(0, at);
|
||||
const domain = address.slice(at + 1);
|
||||
if (local.length > 64 || local.includes("@")) {
|
||||
return false;
|
||||
}
|
||||
// A domain of at least two dot-separated labels, each alphanumeric with internal hyphens. This
|
||||
// rejects a bare hostname, which is legal SMTP but never a real transactional recipient and is a
|
||||
// common shape for a typo.
|
||||
return /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/iu.test(
|
||||
domain,
|
||||
);
|
||||
}
|
||||
|
||||
/** A display name that can be placed in a `From:` without escaping surprises. Quotes and backslashes
|
||||
* are refused rather than escaped, for the same reason control characters are. */
|
||||
export function isValidMailDisplayName(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.trim().length > 0 &&
|
||||
value.length <= MAIL_MAX_DISPLAY_NAME_LENGTH &&
|
||||
!hasUnsafeCharacters(value) &&
|
||||
!/["\\]/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one message and normalizes its recipients.
|
||||
*
|
||||
* Recipient problems get their own code because they are the one failure an operator or a user can
|
||||
* usually act on — a mistyped address — while everything else here is a programming error in the
|
||||
* calling feature.
|
||||
*/
|
||||
export function validateMailMessage(message: MailMessage): ValidatedMailMessage {
|
||||
const recipients = (Array.isArray(message.to) ? message.to : [message.to]) as readonly unknown[];
|
||||
if (recipients.length === 0 || recipients.length > MAIL_MAX_RECIPIENTS) {
|
||||
throw new MailError("MAIL_RECIPIENT_INVALID");
|
||||
}
|
||||
const to: string[] = [];
|
||||
for (const recipient of recipients) {
|
||||
if (!isValidMailAddress(recipient)) {
|
||||
throw new MailError("MAIL_RECIPIENT_INVALID");
|
||||
}
|
||||
to.push(recipient.trim());
|
||||
}
|
||||
if (new Set(to.map((address) => address.toLowerCase())).size !== to.length) {
|
||||
throw new MailError("MAIL_RECIPIENT_INVALID");
|
||||
}
|
||||
|
||||
const subject = message.subject;
|
||||
if (
|
||||
typeof subject !== "string" ||
|
||||
subject.trim().length === 0 ||
|
||||
subject.length > MAIL_MAX_SUBJECT_LENGTH ||
|
||||
hasUnsafeCharacters(subject)
|
||||
) {
|
||||
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||
}
|
||||
|
||||
// At least one body. A message with neither is not a message, and silently sending an empty one
|
||||
// would be worse than refusing it.
|
||||
const hasText = typeof message.text === "string" && message.text.length > 0;
|
||||
const hasHtml = typeof message.html === "string" && message.html.length > 0;
|
||||
if (!hasText && !hasHtml) {
|
||||
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||
}
|
||||
if (hasText && byteLength(message.text as string) > MAIL_MAX_TEXT_BYTES) {
|
||||
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||
}
|
||||
if (hasHtml && byteLength(message.html as string) > MAIL_MAX_HTML_BYTES) {
|
||||
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||
}
|
||||
|
||||
if (message.replyTo !== undefined && !isValidMailAddress(message.replyTo)) {
|
||||
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
to: Object.freeze(to),
|
||||
subject,
|
||||
...(hasText ? { text: message.text as string } : {}),
|
||||
...(hasHtml ? { html: message.html as string } : {}),
|
||||
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo.trim() }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ValidatedMailMessage } from "./message.js";
|
||||
|
||||
/**
|
||||
* The seam between Ekklesia's mail contract and whatever actually delivers a message.
|
||||
*
|
||||
* This is the **portability boundary made executable**, exactly as `StorageProvider` is for object
|
||||
* storage. Everything above it — the message contract, validation, the sender policy, diagnostics —
|
||||
* is provider-agnostic and would be identical under a future standalone host. Everything below it is
|
||||
* one implementation (`createSmtpMailProvider`) that could be replaced by a provider HTTP API
|
||||
* without any calling feature changing a line.
|
||||
*
|
||||
* It speaks only in **already-validated** messages: recipients, subject, and bodies have been
|
||||
* checked and normalized by `validateMailMessage` before anything reaches here, so a provider never
|
||||
* re-implements those rules and two providers can never disagree about what a caller may send.
|
||||
*
|
||||
* Deliberately absent: any way to ask for the sender, the host, the port, or a credential. A
|
||||
* provider is given configuration at construction and exposes none of it afterwards.
|
||||
*/
|
||||
export interface MailProvider {
|
||||
/**
|
||||
* Sends one message. Resolves with a provider message id when the service accepted it.
|
||||
*
|
||||
* **This is a single bounded attempt, not a delivery guarantee.** Acceptance means the next hop
|
||||
* took responsibility, not that anyone received anything — and a failure after the message data
|
||||
* has been transmitted is genuinely ambiguous, because the server may have accepted it and only
|
||||
* the acknowledgement was lost. Nothing here retries; see ADR 0037.
|
||||
*/
|
||||
send(message: ValidatedMailMessage): Promise<MailSendResult>;
|
||||
/**
|
||||
* Opens a connection and, when credentials are configured, authenticates — then closes, **without
|
||||
* sending anything**. For diagnostics only.
|
||||
*
|
||||
* Its result is never persisted and never influences module lifecycle state: a mail service being
|
||||
* briefly unreachable is an operational condition, not an administrative decision.
|
||||
*/
|
||||
verify(): Promise<void>;
|
||||
/** Releases provider connections. Called once, during API shutdown. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MailSendResult {
|
||||
/**
|
||||
* The provider's own identifier for the accepted message, when it supplies one.
|
||||
*
|
||||
* Safe to log: it identifies a message in the provider's records and contains no recipient, no
|
||||
* subject, and no body. It is deliberately the *only* thing about a sent message this contract
|
||||
* hands back, because a verification or reset email's contents are exactly what must not end up
|
||||
* in an operator's log (ADR 0037).
|
||||
*/
|
||||
readonly messageId?: string;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { createTransport, type Transporter } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
|
||||
import type { MailProviderConfig } from "./config.js";
|
||||
import { MailError } from "./message.js";
|
||||
import type { MailProvider, MailSendResult } from "./provider.js";
|
||||
|
||||
/**
|
||||
* Ekklesia's first real `MailProvider`, speaking SMTP.
|
||||
*
|
||||
* **It is not named after a service, and it must not become one.** No provider's hostname appears
|
||||
* anywhere in this file, and a test asserts that. Everything specific to a deployment arrives as
|
||||
* validated configuration, so the same code addresses a Google Workspace relay, Microsoft 365, a
|
||||
* transactional provider's SMTP endpoint, a local Postfix, or anything else that speaks the
|
||||
* protocol (ADR 0037).
|
||||
*
|
||||
* Server-only, and structurally so: this module imports Nodemailer and reaches the network, and no
|
||||
* browser package can import it — a surface cannot import Core at all, which the boundary checker
|
||||
* enforces.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How long a single SMTP exchange may take before it is abandoned.
|
||||
*
|
||||
* These exist because the object-storage pass learned the lesson the hard way: a network client left
|
||||
* on its library's defaults will wait indefinitely, and a provider that accepts a connection and
|
||||
* then goes silent holds the calling request open forever. Nodemailer's own defaults are two
|
||||
* minutes for a connection and ten for a socket, which is not a deadline anyone would choose for a
|
||||
* password-reset email a person is waiting on.
|
||||
*
|
||||
* `socketTimeout` is the most generous of the three because it also covers transmitting the message
|
||||
* body, and it is inactivity-based rather than total elapsed time, so a large message is never cut
|
||||
* off for being large — only for going quiet. A wholly unresponsive server therefore surfaces as a
|
||||
* structured `MAIL_PROVIDER_UNAVAILABLE` in well under a minute rather than never.
|
||||
*/
|
||||
export interface SmtpTransportBounds {
|
||||
/** Milliseconds allowed to establish the TCP connection. */
|
||||
readonly connectionTimeoutMs: number;
|
||||
/** Milliseconds allowed for the server's opening greeting. */
|
||||
readonly greetingTimeoutMs: number;
|
||||
/** Milliseconds the socket may sit idle mid-exchange; resets whenever bytes move. */
|
||||
readonly socketTimeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TRANSPORT_BOUNDS: SmtpTransportBounds = Object.freeze({
|
||||
connectionTimeoutMs: 10_000,
|
||||
greetingTimeoutMs: 10_000,
|
||||
socketTimeoutMs: 30_000,
|
||||
});
|
||||
|
||||
export const SMTP_CONNECTION_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.connectionTimeoutMs;
|
||||
export const SMTP_GREETING_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.greetingTimeoutMs;
|
||||
export const SMTP_SOCKET_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.socketTimeoutMs;
|
||||
|
||||
interface SmtpLikeError {
|
||||
readonly code?: unknown;
|
||||
readonly responseCode?: unknown;
|
||||
readonly rejected?: unknown;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string {
|
||||
const candidate = error as SmtpLikeError | null;
|
||||
return typeof candidate?.code === "string" ? candidate.code : "";
|
||||
}
|
||||
|
||||
function responseCode(error: unknown): number | undefined {
|
||||
const candidate = error as SmtpLikeError | null;
|
||||
return typeof candidate?.responseCode === "number" ? candidate.responseCode : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces any Nodemailer or socket failure to one of the contract's closed codes.
|
||||
*
|
||||
* **Nothing from the caught error survives this function** — not its message, not the server's
|
||||
* response text, not the greeting, not the auth challenge, and not the envelope. SMTP servers
|
||||
* routinely echo back what was sent to them, and an authentication failure's response can name the
|
||||
* account; forwarding any of that verbatim is how a credential or a recipient reaches a log.
|
||||
*/
|
||||
export function translateMailError(error: unknown): MailError {
|
||||
const code = errorCode(error);
|
||||
const status = responseCode(error);
|
||||
|
||||
// A named recipient the server refused: the one failure a human can usually act on, so it keeps
|
||||
// its own code rather than collapsing into a generic send failure.
|
||||
if (code === "EENVELOPE") {
|
||||
const rejected = (error as SmtpLikeError).rejected;
|
||||
if (Array.isArray(rejected) && rejected.length > 0) {
|
||||
return new MailError("MAIL_RECIPIENT_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
// Connection-level failures, including both timeouts configured above.
|
||||
if (
|
||||
code === "ECONNECTION" ||
|
||||
code === "ESOCKET" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "EDNS" ||
|
||||
code === "ECONNRESET" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "EHOSTUNREACH" ||
|
||||
code === "ENOTFOUND"
|
||||
) {
|
||||
return new MailError("MAIL_PROVIDER_UNAVAILABLE");
|
||||
}
|
||||
|
||||
if (status !== undefined) {
|
||||
// 4xx is SMTP's "try later" and 5xx its "no". Splitting on that is what lets a caller tell a
|
||||
// transient outage from a message that will never be accepted.
|
||||
return status >= 500
|
||||
? new MailError("MAIL_REJECTED")
|
||||
: new MailError("MAIL_PROVIDER_UNAVAILABLE");
|
||||
}
|
||||
|
||||
return new MailError("MAIL_SEND_FAILED");
|
||||
}
|
||||
|
||||
function transportOptions(
|
||||
config: MailProviderConfig,
|
||||
bounds: SmtpTransportBounds,
|
||||
): SMTPTransport.Options {
|
||||
return {
|
||||
// Bounded on every axis. See the constants above.
|
||||
connectionTimeout: bounds.connectionTimeoutMs,
|
||||
// Defence in depth: this platform never sends attachments, so Nodemailer must never be able to
|
||||
// read a local file or fetch a URL on the strength of something that reached a message body.
|
||||
disableFileAccess: true,
|
||||
disableUrlAccess: true,
|
||||
greetingTimeout: bounds.greetingTimeoutMs,
|
||||
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
|
||||
// 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
|
||||
// harder to reason about.
|
||||
port: config.port,
|
||||
// `secure` means implicit TLS from the first byte. For STARTTLS it must be false and the upgrade
|
||||
// demanded separately, below.
|
||||
secure: config.security === "implicit-tls",
|
||||
socketTimeout: bounds.socketTimeoutMs,
|
||||
// The downgrade guard. With `requireTLS`, a STARTTLS transport refuses to continue in the clear
|
||||
// if the server does not offer the upgrade — so a server that stops advertising it, or an
|
||||
// attacker who strips it from the EHLO response, produces a failed send rather than a plaintext
|
||||
// session carrying a password. `ignoreTLS` is the opposite instruction and is set only for the
|
||||
// mode that has explicitly asked for no TLS at all, which production configuration refuses.
|
||||
...(config.security === "starttls" ? { requireTLS: true } : {}),
|
||||
...(config.security === "plaintext" ? { ignoreTLS: true } : {}),
|
||||
// Certificate verification is left at Nodemailer's default, which verifies. There is
|
||||
// deliberately no configuration path that could switch it off, and a test asserts this file
|
||||
// never names the option: a setting that disables verification is one someone eventually
|
||||
// disables in production.
|
||||
...(config.username === undefined || config.password === undefined
|
||||
? {}
|
||||
: { auth: { pass: config.password, user: config.username } }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSmtpMailProvider(
|
||||
config: MailProviderConfig,
|
||||
/** Tightened only by the live provider suite, so it can prove a deadline actually fires without
|
||||
* spending the production budget waiting for it. Hosts always take the defaults. */
|
||||
bounds: Partial<SmtpTransportBounds> = {},
|
||||
): MailProvider {
|
||||
const transporter: Transporter<SMTPTransport.SentMessageInfo> = createTransport(
|
||||
transportOptions(config, { ...DEFAULT_TRANSPORT_BOUNDS, ...bounds }),
|
||||
);
|
||||
|
||||
// Built once, from host configuration, and applied to every message. A caller cannot reach it and
|
||||
// there is no per-message override, so no feature can send as anyone else.
|
||||
const from =
|
||||
config.fromName === undefined
|
||||
? { address: config.fromAddress, name: "" }
|
||||
: { address: config.fromAddress, name: config.fromName };
|
||||
|
||||
const provider: MailProvider = {
|
||||
async close(): Promise<void> {
|
||||
transporter.close();
|
||||
},
|
||||
|
||||
async send(message): Promise<MailSendResult> {
|
||||
try {
|
||||
const accepted = await transporter.sendMail({
|
||||
from,
|
||||
subject: message.subject,
|
||||
to: [...message.to],
|
||||
...(message.text === undefined ? {} : { text: message.text }),
|
||||
...(message.html === undefined ? {} : { html: message.html }),
|
||||
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
||||
});
|
||||
// Exactly one attempt. Nodemailer does not retry a `sendMail` on its own, and nothing here
|
||||
// adds one: a failure after the message data has gone out is ambiguous, and retrying an
|
||||
// ambiguous send is how one password-reset request becomes three emails (ADR 0037).
|
||||
const messageId = (accepted as { messageId?: unknown }).messageId;
|
||||
return Object.freeze(typeof messageId === "string" ? { messageId } : {});
|
||||
} catch (error) {
|
||||
throw translateMailError(error);
|
||||
}
|
||||
},
|
||||
|
||||
async verify(): Promise<void> {
|
||||
try {
|
||||
await transporter.verify();
|
||||
} catch (error) {
|
||||
throw translateMailError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
return Object.freeze(provider);
|
||||
}
|
||||
+164
-18
@@ -5,6 +5,7 @@ import {
|
||||
isVersionCompatible,
|
||||
type ModuleCapabilityDeclaration,
|
||||
type ModuleManifest,
|
||||
type PermissionId,
|
||||
safeParseModuleManifest,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -56,15 +57,34 @@ export interface ModuleDiscoveryIssue {
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One declared browser contribution, with its artifact resolved and validated exactly like every
|
||||
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
|
||||
* what it renders.
|
||||
*/
|
||||
export interface ResolvedModuleContribution {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
readonly artifactPath: string;
|
||||
}
|
||||
|
||||
export interface ResolvedModuleEntrypoints {
|
||||
readonly server: string;
|
||||
readonly schema?: string;
|
||||
readonly migrations?: string;
|
||||
readonly jobs?: string;
|
||||
/** A module's declared presentation artifact (ADR 0021), resolved and validated exactly like the
|
||||
* other entrypoints. Discovery only proves the file exists, is compiled ESM, and stays inside the
|
||||
* module package — nothing in this repository imports, evaluates, mounts, or renders it yet. */
|
||||
/** A module's primary presentation artifact (ADR 0021), resolved and validated exactly like the
|
||||
* other executable entrypoints. Discovery never imports or evaluates browser artifacts. */
|
||||
readonly ui?: string;
|
||||
/** The independently mountable full-viewport artifact and its statically declared access policy.
|
||||
* `artifactPath` is canonical and package-contained; `permission` already matched one of this
|
||||
* module's own manifest declarations during manifest parsing. */
|
||||
readonly display?: {
|
||||
readonly artifactPath: string;
|
||||
readonly permission: PermissionId;
|
||||
};
|
||||
}
|
||||
|
||||
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
||||
@@ -85,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
||||
|
||||
export interface DiscoveredModule {
|
||||
readonly directory: string;
|
||||
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
|
||||
* inside this module package. Empty for a module that contributes nothing. */
|
||||
readonly contributions: readonly ResolvedModuleContribution[];
|
||||
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||
readonly manifest: ReadonlyModuleManifest;
|
||||
readonly manifestPath: string;
|
||||
@@ -103,6 +126,7 @@ interface ModulePackageData {
|
||||
}
|
||||
|
||||
interface ModuleCandidate {
|
||||
contributions: readonly ResolvedModuleContribution[];
|
||||
directory: string;
|
||||
directoryName: string;
|
||||
entrypoints: ResolvedModuleEntrypoints;
|
||||
@@ -250,19 +274,73 @@ function validatePackageData(
|
||||
return { name, version };
|
||||
}
|
||||
|
||||
interface ResolvedModuleArtifacts {
|
||||
readonly contributions: readonly ResolvedModuleContribution[];
|
||||
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||
}
|
||||
|
||||
async function resolveEntrypoints(
|
||||
moduleDirectory: string,
|
||||
directoryName: string,
|
||||
manifest: ModuleManifest,
|
||||
issues: ModuleDiscoveryIssue[],
|
||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
||||
): Promise<ResolvedModuleArtifacts | undefined> {
|
||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||
const resolvedContributions = new Map<string, string>();
|
||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
.map(([entrypointName, relativeEntrypoint]) => ({
|
||||
entrypointName,
|
||||
issuePath: `entrypoints.${entrypointName}`,
|
||||
relativeEntrypoint,
|
||||
}));
|
||||
// A contribution artifact is resolved through the identical loop as every other compiled
|
||||
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
|
||||
// are one implementation, so a contribution can never quietly receive weaker checks than
|
||||
// `entrypoints.ui` (ADR 0041).
|
||||
const configuredContributions = manifest.contributions.map((contribution, index) => ({
|
||||
entrypointName: `contribution:${contribution.id}`,
|
||||
issuePath: `contributions.${index}.artifact`,
|
||||
relativeEntrypoint: contribution.artifact,
|
||||
}));
|
||||
const configuredEntrypoints = [
|
||||
...ordinaryConfiguredEntrypoints,
|
||||
...(display === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
entrypointName: "display",
|
||||
issuePath: "entrypoints.display.artifact",
|
||||
relativeEntrypoint: display.artifact,
|
||||
},
|
||||
]),
|
||||
...configuredContributions,
|
||||
];
|
||||
let entrypointCollision = false;
|
||||
|
||||
for (const [entrypointName, relativeEntrypoint] of Object.entries(manifest.entrypoints)) {
|
||||
if (relativeEntrypoint === undefined) {
|
||||
continue;
|
||||
if (display !== undefined) {
|
||||
const configuredDisplayPath = path.resolve(moduleDirectory, display.artifact);
|
||||
for (const ordinary of ordinaryConfiguredEntrypoints) {
|
||||
if (path.resolve(moduleDirectory, ordinary.relativeEntrypoint) === configuredDisplayPath) {
|
||||
entrypointCollision = true;
|
||||
issues.push({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: `display must not resolve to the same configured file as ${ordinary.entrypointName}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "entrypoints.display.artifact",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const {
|
||||
entrypointName,
|
||||
issuePath: entrypointIssuePath,
|
||||
relativeEntrypoint,
|
||||
} of configuredEntrypoints) {
|
||||
if (
|
||||
isCompiledEntrypoint(entrypointName) &&
|
||||
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
||||
@@ -272,7 +350,7 @@ async function resolveEntrypoints(
|
||||
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -283,7 +361,7 @@ async function resolveEntrypoints(
|
||||
message: `${entrypointName} resolves outside its module directory.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -296,7 +374,7 @@ async function resolveEntrypoints(
|
||||
message: `${entrypointName} resolves through a link outside its module directory.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -312,11 +390,18 @@ async function resolveEntrypoints(
|
||||
: `${entrypointName} must resolve to a file.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||
if (entrypointName.startsWith("contribution:")) {
|
||||
resolvedContributions.set(
|
||||
entrypointName.slice("contribution:".length),
|
||||
canonicalEntrypoint,
|
||||
);
|
||||
} else {
|
||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = errorCode(error);
|
||||
issues.push({
|
||||
@@ -329,22 +414,82 @@ async function resolveEntrypoints(
|
||||
}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved.server) {
|
||||
if (resolved.display !== undefined) {
|
||||
for (const [entrypointName, canonicalEntrypoint] of Object.entries(resolved)) {
|
||||
if (entrypointName !== "display" && canonicalEntrypoint === resolved.display) {
|
||||
entrypointCollision = true;
|
||||
issues.push({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: `display must not resolve through a link to the same file as ${entrypointName}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "entrypoints.display.artifact",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A contribution artifact must be its own file. Two declarations resolving to one path — through
|
||||
// a link, since the manifest already rejects two identical declared paths — would make one
|
||||
// contribution silently serve another's bytes, and the loader's identity check would then reject
|
||||
// whichever one lost.
|
||||
const canonicalContributionPaths = new Map<string, string>();
|
||||
for (const [contributionId, canonicalPath] of resolvedContributions) {
|
||||
const claimedBy = canonicalContributionPaths.get(canonicalPath);
|
||||
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
|
||||
entrypointCollision = true;
|
||||
issues.push({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "contributions",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
canonicalContributionPaths.set(canonicalPath, contributionId);
|
||||
}
|
||||
|
||||
if (
|
||||
!resolved.server ||
|
||||
entrypointCollision ||
|
||||
resolvedContributions.size !== manifest.contributions.length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
const contributions = manifest.contributions.map((contribution) =>
|
||||
Object.freeze({
|
||||
artifactPath: resolvedContributions.get(contribution.id) as string,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: contribution.id,
|
||||
version: contribution.version,
|
||||
}),
|
||||
);
|
||||
|
||||
const entrypoints: ResolvedModuleEntrypoints = {
|
||||
server: resolved.server,
|
||||
...(resolved.schema ? { schema: resolved.schema } : {}),
|
||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
|
||||
...(resolved.ui ? { ui: resolved.ui } : {}),
|
||||
...(resolved.display && display
|
||||
? {
|
||||
display: {
|
||||
artifactPath: resolved.display,
|
||||
permission: display.permission,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return { contributions: Object.freeze(contributions), entrypoints };
|
||||
}
|
||||
|
||||
async function scanModuleDirectory(
|
||||
@@ -442,15 +587,16 @@ async function scanModuleDirectory(
|
||||
});
|
||||
}
|
||||
|
||||
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||
if (!entrypoints) {
|
||||
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||
if (!artifacts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
contributions: artifacts.contributions,
|
||||
directory: canonicalModuleDirectory,
|
||||
directoryName,
|
||||
entrypoints,
|
||||
entrypoints: artifacts.entrypoints,
|
||||
manifest,
|
||||
manifestPath: canonicalManifestPath,
|
||||
packageName: packageData.name,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
export * from "./configuration.js";
|
||||
export * from "./configuration-schema.js";
|
||||
export * from "./configuration-store.js";
|
||||
export * from "./configuration.js";
|
||||
export * from "./database.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
export * from "./lifecycle-store.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./realtime.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
export * from "./ui-publication.js";
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import {
|
||||
MODULE_REALTIME_MAX_CHANNELS_PER_MODULE,
|
||||
MODULE_REALTIME_MAX_PAYLOAD_BYTES,
|
||||
MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL,
|
||||
type ModuleId,
|
||||
type ModuleRealtimeChannel,
|
||||
type ModuleRealtimeChannelDefinitionInput,
|
||||
type ModuleRealtimeChannelRegistrar,
|
||||
ModuleRealtimeError,
|
||||
type ModuleRealtimePublisher,
|
||||
moduleIdSchema,
|
||||
moduleRealtimeChannelSchema,
|
||||
type PermissionId,
|
||||
safeParseModuleRealtimeChannelDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
export type ModuleRealtimeRegistrationIssueCode =
|
||||
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
|
||||
| "MODULE_REALTIME_CHANNEL_INVALID"
|
||||
| "MODULE_REALTIME_PERMISSION_INVALID"
|
||||
| "MODULE_REALTIME_PERMISSION_UNDECLARED"
|
||||
| "MODULE_REALTIME_CHANNEL_DUPLICATE"
|
||||
| "MODULE_REALTIME_CHANNEL_LIMIT";
|
||||
|
||||
/** An internal fixed-code signal. The runtime converts it to its ordinary structured issue shape. */
|
||||
export class ModuleRealtimeRegistrationError extends Error {
|
||||
readonly code: ModuleRealtimeRegistrationIssueCode;
|
||||
|
||||
constructor(code: ModuleRealtimeRegistrationIssueCode) {
|
||||
super("Module realtime channel registration failed.");
|
||||
this.name = "ModuleRealtimeRegistrationError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisteredModuleRealtimeChannel {
|
||||
readonly moduleId: ModuleId;
|
||||
readonly channel: ModuleRealtimeChannel;
|
||||
readonly permission: PermissionId;
|
||||
}
|
||||
|
||||
/** A host-facing opaque publication. `serializedPayload` is the exact JSON snapshot serialized once
|
||||
* at publish time; hosts forward it without inspecting, mutating, or re-signing values inside it. */
|
||||
export interface ModuleRealtimePublication {
|
||||
readonly sequence: number;
|
||||
readonly serializedPayload: string;
|
||||
}
|
||||
|
||||
/** `snapshot` is captured atomically with installing this subscription. `read` then yields ordered
|
||||
* later publications. A slow reader retains only the newest pending publication. */
|
||||
export interface ModuleRealtimeSubscription {
|
||||
readonly snapshot: ModuleRealtimePublication;
|
||||
read(): Promise<ModuleRealtimePublication | undefined>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ModuleRealtimeRegistry {
|
||||
readonly channels: readonly RegisteredModuleRealtimeChannel[];
|
||||
/** Refuses new subscriptions and closes existing streams while publishers remain usable for
|
||||
* ordinary HTTP requests already draining. Publisher lifetime remains Core runtime-owned. */
|
||||
closeSubscriptions(): void;
|
||||
publisherFor(moduleId: ModuleId): ModuleRealtimePublisher;
|
||||
subscribe(moduleId: ModuleId, channel: string): ModuleRealtimeSubscription;
|
||||
}
|
||||
|
||||
const emptyRealtimePublisher: ModuleRealtimePublisher = Object.freeze({
|
||||
publish: (): never => {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
},
|
||||
});
|
||||
|
||||
/** Explicit no-channel host composition for tests and hosts that start no modules. */
|
||||
export const EMPTY_MODULE_REALTIME_REGISTRY: ModuleRealtimeRegistry = Object.freeze({
|
||||
channels: Object.freeze([]),
|
||||
closeSubscriptions: (): void => undefined,
|
||||
publisherFor: (): ModuleRealtimePublisher => emptyRealtimePublisher,
|
||||
subscribe: (): never => {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
},
|
||||
});
|
||||
|
||||
interface ChannelState {
|
||||
readonly definition: RegisteredModuleRealtimeChannel;
|
||||
readonly subscriptions: Set<BoundedModuleRealtimeSubscription>;
|
||||
publication?: ModuleRealtimePublication;
|
||||
}
|
||||
|
||||
interface RealtimeRegistrationStage {
|
||||
readonly realtime: ModuleRealtimeChannelRegistrar;
|
||||
commit(): void;
|
||||
}
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
type JsonWriteResult = "invalid" | "success" | "too-large";
|
||||
|
||||
/** Accumulates at most the public payload ceiling. Each encoded fragment is itself bounded, so an
|
||||
* oversized value cannot create an oversized temporary serialization before being refused. */
|
||||
class BoundedJsonWriter {
|
||||
readonly #chunks: string[] = [];
|
||||
#bytes = 0;
|
||||
|
||||
get remainingBytes(): number {
|
||||
return MODULE_REALTIME_MAX_PAYLOAD_BYTES - this.#bytes;
|
||||
}
|
||||
|
||||
append(fragment: string): boolean {
|
||||
const bytes = textEncoder.encode(fragment).byteLength;
|
||||
if (bytes > this.remainingBytes) {
|
||||
return false;
|
||||
}
|
||||
this.#chunks.push(fragment);
|
||||
this.#bytes += bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
finish(): string {
|
||||
return this.#chunks.join("");
|
||||
}
|
||||
}
|
||||
|
||||
function appendRawString(
|
||||
writer: BoundedJsonWriter,
|
||||
value: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): boolean {
|
||||
let offset = start;
|
||||
while (offset < end) {
|
||||
let chunkEnd = Math.min(offset + 1_024, end);
|
||||
const finalCodeUnit = value.charCodeAt(chunkEnd - 1);
|
||||
if (
|
||||
chunkEnd < end &&
|
||||
finalCodeUnit >= 0xd800 &&
|
||||
finalCodeUnit <= 0xdbff &&
|
||||
value.charCodeAt(chunkEnd) >= 0xdc00 &&
|
||||
value.charCodeAt(chunkEnd) <= 0xdfff
|
||||
) {
|
||||
chunkEnd -= 1;
|
||||
}
|
||||
if (!writer.append(value.slice(offset, chunkEnd))) {
|
||||
return false;
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapedCodeUnit(codeUnit: number): string | undefined {
|
||||
switch (codeUnit) {
|
||||
case 0x08:
|
||||
return "\\b";
|
||||
case 0x09:
|
||||
return "\\t";
|
||||
case 0x0a:
|
||||
return "\\n";
|
||||
case 0x0c:
|
||||
return "\\f";
|
||||
case 0x0d:
|
||||
return "\\r";
|
||||
case 0x22:
|
||||
return '\\"';
|
||||
case 0x5c:
|
||||
return "\\\\";
|
||||
default:
|
||||
return codeUnit <= 0x1f ? `\\u${codeUnit.toString(16).padStart(4, "0")}` : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the same JSON string representation as `JSON.stringify`, including escaping lone UTF-16
|
||||
* surrogates, but stops the moment the output budget cannot be met. */
|
||||
function writeJsonString(writer: BoundedJsonWriter, value: string): JsonWriteResult {
|
||||
// Every UTF-16 code unit contributes at least one UTF-8 byte to JSON. This rejects an enormous
|
||||
// string without first copying or stringifying it.
|
||||
if (value.length + 2 > writer.remainingBytes) {
|
||||
return "too-large";
|
||||
}
|
||||
if (!writer.append('"')) {
|
||||
return "too-large";
|
||||
}
|
||||
let rawStart = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
let escaped = escapedCodeUnit(codeUnit);
|
||||
if (escaped === undefined && codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const following = value.charCodeAt(index + 1);
|
||||
if (following >= 0xdc00 && following <= 0xdfff) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
escaped = `\\u${codeUnit.toString(16)}`;
|
||||
} else if (escaped === undefined && codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
escaped = `\\u${codeUnit.toString(16)}`;
|
||||
}
|
||||
if (escaped === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!appendRawString(writer, value, rawStart, index) || !writer.append(escaped)) {
|
||||
return "too-large";
|
||||
}
|
||||
rawStart = index + 1;
|
||||
}
|
||||
if (!appendRawString(writer, value, rawStart, value.length) || !writer.append('"')) {
|
||||
return "too-large";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
function writeJsonValue(
|
||||
writer: BoundedJsonWriter,
|
||||
value: unknown,
|
||||
ancestors: Set<object>,
|
||||
depth: number,
|
||||
): JsonWriteResult {
|
||||
if (value === null) {
|
||||
return writer.append("null") ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return writeJsonString(writer, value);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return writer.append(value ? "true" : "false") ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "invalid";
|
||||
}
|
||||
// A finite JavaScript number's JSON spelling is tiny and cannot be an unbounded allocation.
|
||||
const serializedNumber = JSON.stringify(value);
|
||||
return writer.append(serializedNumber) ? "success" : "too-large";
|
||||
}
|
||||
if (typeof value !== "object" || depth > 64 || ancestors.has(value)) {
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
// Never execute user-defined serialization hooks. A non-enumerable `toJSON` still changes
|
||||
// `JSON.stringify` output, so silently ignoring it would make validation and wire semantics
|
||||
// disagree. Reject the hook itself without reading or invoking its value.
|
||||
if (Object.hasOwn(value, "toJSON")) {
|
||||
return "invalid";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype) {
|
||||
return "invalid";
|
||||
}
|
||||
// Brackets, one single-byte value per element, and separators are the smallest possible
|
||||
// representation. If even that cannot fit, do not enumerate a potentially enormous array.
|
||||
const minimumBytes = value.length === 0 ? 2 : value.length * 2 + 1;
|
||||
if (minimumBytes > writer.remainingBytes) {
|
||||
return "too-large";
|
||||
}
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue;
|
||||
}
|
||||
if (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length) {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
if (!writer.append("[")) {
|
||||
return "too-large";
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
return "invalid";
|
||||
}
|
||||
if (index > 0 && !writer.append(",")) {
|
||||
return "too-large";
|
||||
}
|
||||
const result = writeJsonValue(writer, descriptor.value, ancestors, depth + 1);
|
||||
if (result !== "success") {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return writer.append("]") ? "success" : "too-large";
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
return "invalid";
|
||||
}
|
||||
if (!writer.append("{")) {
|
||||
return "too-large";
|
||||
}
|
||||
let entryCount = 0;
|
||||
for (const key in value) {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
continue;
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
return "invalid";
|
||||
}
|
||||
if (entryCount > 0 && !writer.append(",")) {
|
||||
return "too-large";
|
||||
}
|
||||
const keyResult = writeJsonString(writer, key);
|
||||
if (keyResult !== "success") {
|
||||
return keyResult;
|
||||
}
|
||||
if (!writer.append(":")) {
|
||||
return "too-large";
|
||||
}
|
||||
const valueResult = writeJsonValue(writer, descriptor.value, ancestors, depth + 1);
|
||||
if (valueResult !== "success") {
|
||||
return valueResult;
|
||||
}
|
||||
entryCount += 1;
|
||||
}
|
||||
return writer.append("}") ? "success" : "too-large";
|
||||
} catch {
|
||||
return "invalid";
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export type ModuleRealtimePayloadSerializationResult =
|
||||
| { readonly success: true; readonly serializedPayload: string }
|
||||
| { readonly success: false; readonly code: "REALTIME_PAYLOAD_INVALID" }
|
||||
| { readonly success: false; readonly code: "REALTIME_PAYLOAD_TOO_LARGE" };
|
||||
|
||||
/** Serializes once and reports only a fixed code. It never returns or embeds rejected input. */
|
||||
export function serializeModuleRealtimePayload(
|
||||
payload: unknown,
|
||||
): ModuleRealtimePayloadSerializationResult {
|
||||
const writer = new BoundedJsonWriter();
|
||||
const result = writeJsonValue(writer, payload, new Set<object>(), 0);
|
||||
if (result === "invalid") {
|
||||
return { success: false, code: "REALTIME_PAYLOAD_INVALID" };
|
||||
}
|
||||
if (result === "too-large") {
|
||||
return { success: false, code: "REALTIME_PAYLOAD_TOO_LARGE" };
|
||||
}
|
||||
return { success: true, serializedPayload: writer.finish() };
|
||||
}
|
||||
|
||||
class BoundedModuleRealtimeSubscription implements ModuleRealtimeSubscription {
|
||||
readonly snapshot: ModuleRealtimePublication;
|
||||
#closed = false;
|
||||
#pending: ModuleRealtimePublication | undefined;
|
||||
#reader: ((publication: ModuleRealtimePublication | undefined) => void) | undefined;
|
||||
readonly #onClose: () => void;
|
||||
|
||||
constructor(snapshot: ModuleRealtimePublication, onClose: () => void) {
|
||||
this.snapshot = snapshot;
|
||||
this.#onClose = onClose;
|
||||
}
|
||||
|
||||
push(publication: ModuleRealtimePublication): void {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
const reader = this.#reader;
|
||||
if (reader) {
|
||||
this.#reader = undefined;
|
||||
reader(publication);
|
||||
return;
|
||||
}
|
||||
// One slot, overwritten by the newest publication. A publisher never waits for a browser.
|
||||
this.#pending = publication;
|
||||
}
|
||||
|
||||
read(): Promise<ModuleRealtimePublication | undefined> {
|
||||
if (this.#closed) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
if (this.#pending) {
|
||||
const publication = this.#pending;
|
||||
this.#pending = undefined;
|
||||
return Promise.resolve(publication);
|
||||
}
|
||||
if (this.#reader) {
|
||||
return Promise.reject(new ModuleRealtimeError("REALTIME_UNAVAILABLE"));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.#reader = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
this.#closed = true;
|
||||
this.#pending = undefined;
|
||||
const reader = this.#reader;
|
||||
this.#reader = undefined;
|
||||
this.#onClose();
|
||||
reader?.(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function stateKey(moduleId: string, channel: string): string {
|
||||
return `${moduleId}\0${channel}`;
|
||||
}
|
||||
|
||||
/** Registration is staged per module, while state and subscriptions stay private and bounded. */
|
||||
export class StagedModuleRealtimeRegistry {
|
||||
readonly #channels: RegisteredModuleRealtimeChannel[] = [];
|
||||
readonly #publishers = new Map<ModuleId, ModuleRealtimePublisher>();
|
||||
readonly #states = new Map<string, ChannelState>();
|
||||
readonly #activeModules = new Set<ModuleId>();
|
||||
#acceptingSubscriptions = true;
|
||||
|
||||
createRegistrationStage(
|
||||
moduleId: ModuleId,
|
||||
ownedPermissionIds: ReadonlySet<PermissionId>,
|
||||
): RealtimeRegistrationStage {
|
||||
const local: RegisteredModuleRealtimeChannel[] = [];
|
||||
const localNames = new Set<ModuleRealtimeChannel>();
|
||||
const realtime: ModuleRealtimeChannelRegistrar = Object.freeze({
|
||||
channel: (definition: ModuleRealtimeChannelDefinitionInput): void => {
|
||||
const parsed = safeParseModuleRealtimeChannelDefinition(definition);
|
||||
if (!parsed.success) {
|
||||
throw new ModuleRealtimeRegistrationError(parsed.code);
|
||||
}
|
||||
if (!ownedPermissionIds.has(parsed.data.permission)) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_PERMISSION_UNDECLARED");
|
||||
}
|
||||
if (localNames.has(parsed.data.channel)) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_DUPLICATE");
|
||||
}
|
||||
if (local.length >= MODULE_REALTIME_MAX_CHANNELS_PER_MODULE) {
|
||||
throw new ModuleRealtimeRegistrationError("MODULE_REALTIME_CHANNEL_LIMIT");
|
||||
}
|
||||
localNames.add(parsed.data.channel);
|
||||
local.push(
|
||||
Object.freeze({
|
||||
channel: parsed.data.channel,
|
||||
moduleId,
|
||||
permission: parsed.data.permission,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
realtime,
|
||||
commit: (): void => {
|
||||
for (const definition of local) {
|
||||
this.#channels.push(definition);
|
||||
this.#states.set(stateKey(moduleId, definition.channel), {
|
||||
definition,
|
||||
subscriptions: new Set(),
|
||||
});
|
||||
}
|
||||
this.#activeModules.add(moduleId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
publisherFor(moduleId: ModuleId): ModuleRealtimePublisher {
|
||||
let publisher = this.#publishers.get(moduleId);
|
||||
if (!publisher) {
|
||||
publisher = Object.freeze({
|
||||
publish: (channel: string, payload: unknown): void => {
|
||||
this.#publish(moduleId, channel, payload);
|
||||
},
|
||||
});
|
||||
this.#publishers.set(moduleId, publisher);
|
||||
}
|
||||
return publisher;
|
||||
}
|
||||
|
||||
missingSnapshots(moduleId: ModuleId): readonly ModuleRealtimeChannel[] {
|
||||
return Object.freeze(
|
||||
this.#channels
|
||||
.filter((definition) => definition.moduleId === moduleId)
|
||||
.filter(
|
||||
(definition) => !this.#states.get(stateKey(moduleId, definition.channel))?.publication,
|
||||
)
|
||||
.map((definition) => definition.channel),
|
||||
);
|
||||
}
|
||||
|
||||
closeSubscriptions(): void {
|
||||
this.#acceptingSubscriptions = false;
|
||||
for (const state of this.#states.values()) {
|
||||
for (const subscription of [...state.subscriptions]) {
|
||||
subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes one module's captured publisher inert immediately before that module's stop hook. Other
|
||||
* dependency modules remain live until reverse-order shutdown reaches their own hook. */
|
||||
deactivateModule(moduleId: ModuleId): void {
|
||||
this.#activeModules.delete(moduleId);
|
||||
for (const state of this.#states.values()) {
|
||||
if (state.definition.moduleId !== moduleId) {
|
||||
continue;
|
||||
}
|
||||
for (const subscription of [...state.subscriptions]) {
|
||||
subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deactivateAll(): void {
|
||||
this.closeSubscriptions();
|
||||
this.#activeModules.clear();
|
||||
}
|
||||
|
||||
publish(): ModuleRealtimeRegistry {
|
||||
const channels = Object.freeze([...this.#channels]);
|
||||
return Object.freeze({
|
||||
channels,
|
||||
closeSubscriptions: (): void => this.closeSubscriptions(),
|
||||
publisherFor: (moduleId: ModuleId): ModuleRealtimePublisher => this.publisherFor(moduleId),
|
||||
subscribe: (moduleId: ModuleId, channel: string): ModuleRealtimeSubscription =>
|
||||
this.subscribe(moduleId, channel),
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(moduleId: ModuleId, channel: string): ModuleRealtimeSubscription {
|
||||
if (!this.#acceptingSubscriptions) {
|
||||
throw new ModuleRealtimeError("REALTIME_UNAVAILABLE");
|
||||
}
|
||||
if (
|
||||
!moduleIdSchema.safeParse(moduleId).success ||
|
||||
!moduleRealtimeChannelSchema.safeParse(channel).success
|
||||
) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_INVALID");
|
||||
}
|
||||
const state = this.#states.get(stateKey(moduleId, channel));
|
||||
if (!state) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
}
|
||||
if (!state.publication) {
|
||||
throw new ModuleRealtimeError("REALTIME_SNAPSHOT_UNAVAILABLE");
|
||||
}
|
||||
if (state.subscriptions.size >= MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL) {
|
||||
throw new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT");
|
||||
}
|
||||
const subscription = new BoundedModuleRealtimeSubscription(state.publication, () => {
|
||||
state.subscriptions.delete(subscription);
|
||||
});
|
||||
// JavaScript runs this capture and insertion without interleaving. A later publish therefore
|
||||
// either becomes `snapshot` or is observed by `read`, never lost between those two operations.
|
||||
state.subscriptions.add(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
#publish(moduleId: ModuleId, channel: string, payload: unknown): void {
|
||||
if (!this.#activeModules.has(moduleId)) {
|
||||
throw new ModuleRealtimeError("REALTIME_PUBLISHER_STOPPED");
|
||||
}
|
||||
const parsedChannel = moduleRealtimeChannelSchema.safeParse(channel);
|
||||
if (!parsedChannel.success) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_INVALID");
|
||||
}
|
||||
const state = this.#states.get(stateKey(moduleId, parsedChannel.data));
|
||||
if (!state) {
|
||||
throw new ModuleRealtimeError("REALTIME_CHANNEL_UNDECLARED");
|
||||
}
|
||||
const serialized = serializeModuleRealtimePayload(payload);
|
||||
if (!serialized.success) {
|
||||
throw new ModuleRealtimeError(serialized.code);
|
||||
}
|
||||
const previousSequence = state.publication?.sequence ?? 0;
|
||||
if (previousSequence >= Number.MAX_SAFE_INTEGER) {
|
||||
throw new ModuleRealtimeError("REALTIME_UNAVAILABLE");
|
||||
}
|
||||
const publication = Object.freeze({
|
||||
sequence: previousSequence + 1,
|
||||
serializedPayload: serialized.serializedPayload,
|
||||
});
|
||||
state.publication = publication;
|
||||
for (const subscription of state.subscriptions) {
|
||||
subscription.push(publication);
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
-12
@@ -1,20 +1,22 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
type CapabilityId,
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
EMPTY_MODULE_STORAGE,
|
||||
type ModuleConfiguration,
|
||||
type ModuleStorage,
|
||||
type CapabilityRegistrar,
|
||||
type CapabilityResolver,
|
||||
capabilityIdSchema,
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
EMPTY_MODULE_STORAGE,
|
||||
type ModuleConfiguration,
|
||||
type ModuleHttpRouteRegistrar,
|
||||
type ModuleId,
|
||||
ModuleRealtimeError,
|
||||
type ModuleRealtimePublisher,
|
||||
type ModuleRegistrationContext,
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRuntimeContext,
|
||||
type ModuleRuntimeDefinition,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleStorage,
|
||||
moduleRouteShapeKey,
|
||||
moduleRoutesOverlap,
|
||||
parseModuleRuntimeDefinition,
|
||||
@@ -27,6 +29,11 @@ import {
|
||||
discoverModules,
|
||||
type ModuleDiscoveryOptions,
|
||||
} from "./discovery.js";
|
||||
import {
|
||||
ModuleRealtimeRegistrationError,
|
||||
type ModuleRealtimeRegistry,
|
||||
StagedModuleRealtimeRegistry,
|
||||
} from "./realtime.js";
|
||||
|
||||
export type ModuleRuntimePhase = "load" | "register" | "rollback" | "start" | "stop";
|
||||
|
||||
@@ -37,10 +44,23 @@ export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_CAPABILITY_UNDECLARED"
|
||||
| "MODULE_CAPABILITY_UNREGISTERED"
|
||||
| "MODULE_REGISTER_FAILED"
|
||||
| "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID"
|
||||
| "MODULE_REALTIME_CHANNEL_DUPLICATE"
|
||||
| "MODULE_REALTIME_CHANNEL_INVALID"
|
||||
| "MODULE_REALTIME_CHANNEL_LIMIT"
|
||||
| "MODULE_REALTIME_CHANNEL_UNDECLARED"
|
||||
| "MODULE_REALTIME_PAYLOAD_INVALID"
|
||||
| "MODULE_REALTIME_PAYLOAD_TOO_LARGE"
|
||||
| "MODULE_REALTIME_PERMISSION_INVALID"
|
||||
| "MODULE_REALTIME_PERMISSION_UNDECLARED"
|
||||
| "MODULE_REALTIME_PUBLISH_FAILED"
|
||||
| "MODULE_REALTIME_PUBLISHER_STOPPED"
|
||||
| "MODULE_REALTIME_SNAPSHOT_MISSING"
|
||||
| "MODULE_ROLLBACK_FAILED"
|
||||
| "MODULE_ROLLBACK_TIMEOUT"
|
||||
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||
| "MODULE_ROUTE_DUPLICATE"
|
||||
| "MODULE_ROUTE_BODY_MODE_INVALID"
|
||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||
| "MODULE_ROUTE_METHOD_INVALID"
|
||||
| "MODULE_ROUTE_OVERLAP"
|
||||
@@ -77,6 +97,8 @@ export interface StartedModuleRuntime {
|
||||
/** One immutable startup snapshot: discovered-module order, then registration order within each
|
||||
* module. Never mutated after startup — there is no dynamic route addition or removal. */
|
||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||
/** Opaque, transport-neutral state and subscriptions for the host. */
|
||||
readonly realtime: ModuleRealtimeRegistry;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -193,6 +215,23 @@ function runtimeIssue(
|
||||
return { code, message, moduleId, phase };
|
||||
}
|
||||
|
||||
function realtimeRuntimeIssueCode(error: ModuleRealtimeError): ModuleRuntimeIssueCode {
|
||||
switch (error.code) {
|
||||
case "REALTIME_CHANNEL_INVALID":
|
||||
return "MODULE_REALTIME_CHANNEL_INVALID";
|
||||
case "REALTIME_CHANNEL_UNDECLARED":
|
||||
return "MODULE_REALTIME_CHANNEL_UNDECLARED";
|
||||
case "REALTIME_PAYLOAD_INVALID":
|
||||
return "MODULE_REALTIME_PAYLOAD_INVALID";
|
||||
case "REALTIME_PAYLOAD_TOO_LARGE":
|
||||
return "MODULE_REALTIME_PAYLOAD_TOO_LARGE";
|
||||
case "REALTIME_PUBLISHER_STOPPED":
|
||||
return "MODULE_REALTIME_PUBLISHER_STOPPED";
|
||||
default:
|
||||
return "MODULE_REALTIME_PUBLISH_FAILED";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -280,6 +319,7 @@ class StagedCapabilityRegistry {
|
||||
services: ModuleRuntimeServices,
|
||||
config: ModuleConfiguration,
|
||||
storage: ModuleStorage,
|
||||
realtime: ModuleRealtimePublisher,
|
||||
): ModuleRuntimeContext {
|
||||
const capabilities: CapabilityResolver = Object.freeze({
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
@@ -291,6 +331,7 @@ class StagedCapabilityRegistry {
|
||||
capabilities,
|
||||
config,
|
||||
moduleId: module.manifest.id,
|
||||
realtime,
|
||||
services,
|
||||
storage,
|
||||
});
|
||||
@@ -427,6 +468,7 @@ class StagedModuleHttpRoutes {
|
||||
shapes.add(shapeKey);
|
||||
local.push(
|
||||
Object.freeze({
|
||||
bodyMode: parsed.data.bodyMode,
|
||||
handler: parsed.data.handler,
|
||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||
method: parsed.data.method,
|
||||
@@ -528,16 +570,22 @@ async function registerModules(
|
||||
modules: readonly LoadedModule[],
|
||||
capabilityRegistry: StagedCapabilityRegistry,
|
||||
httpRoutes: StagedModuleHttpRoutes,
|
||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
): Promise<void> {
|
||||
for (const module of modules) {
|
||||
const capabilityStage = capabilityRegistry.createRegistrationStage(module.discovered);
|
||||
const routeStage = httpRoutes.createRegistrationStage(module.discovered);
|
||||
const realtimeStage = realtimeRegistry.createRegistrationStage(
|
||||
module.discovered.manifest.id,
|
||||
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
|
||||
);
|
||||
const context: ModuleRegistrationContext = Object.freeze({
|
||||
capabilities: capabilityStage.capabilities,
|
||||
config: configurationFor(configurations, module.discovered.manifest.id),
|
||||
http: routeStage.http,
|
||||
moduleId: module.discovered.manifest.id,
|
||||
realtime: realtimeStage.realtime,
|
||||
});
|
||||
try {
|
||||
await module.definition.register?.(context);
|
||||
@@ -545,6 +593,16 @@ async function registerModules(
|
||||
if (error instanceof ModuleRuntimeSignal) {
|
||||
throw new ModuleRuntimeError([error.issue]);
|
||||
}
|
||||
if (error instanceof ModuleRealtimeRegistrationError) {
|
||||
throw new ModuleRuntimeError([
|
||||
runtimeIssue(
|
||||
error.code,
|
||||
"register",
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} registered an invalid realtime channel.`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
throw new ModuleRuntimeError([
|
||||
runtimeIssue(
|
||||
"MODULE_REGISTER_FAILED",
|
||||
@@ -563,6 +621,7 @@ async function registerModules(
|
||||
// a failed registration therefore publishes no routes for this module, matching the same
|
||||
// all-or-nothing guarantee capabilities already have.
|
||||
routeStage.commit();
|
||||
realtimeStage.commit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,12 +688,16 @@ async function stopModules(
|
||||
services: ModuleRuntimeServices,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
storages: ModuleStorageLookup,
|
||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
|
||||
const issues: ModuleRuntimeIssue[] = [];
|
||||
for (const module of [...modules].reverse()) {
|
||||
const moduleId = module.discovered.manifest.id;
|
||||
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
|
||||
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
|
||||
realtimeRegistry.deactivateModule(moduleId);
|
||||
const stop = module.definition.stop;
|
||||
if (!stop) {
|
||||
// No synthetic callback and no timer for a module that declares no stop hook.
|
||||
@@ -650,6 +713,7 @@ async function stopModules(
|
||||
services,
|
||||
configurationFor(configurations, moduleId),
|
||||
storageFor(storages, moduleId),
|
||||
realtimeRegistry.publisherFor(moduleId),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -689,6 +753,7 @@ async function startModules(
|
||||
services: ModuleRuntimeServices,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
storages: ModuleStorageLookup,
|
||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<void> {
|
||||
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it
|
||||
@@ -708,18 +773,38 @@ async function startModules(
|
||||
services,
|
||||
configurationFor(configurations, module.discovered.manifest.id),
|
||||
storageFor(storages, module.discovered.manifest.id),
|
||||
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
||||
),
|
||||
);
|
||||
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
|
||||
if (missingSnapshots.length > 0) {
|
||||
throw new ModuleRuntimeSignal(
|
||||
runtimeIssue(
|
||||
"MODULE_REALTIME_SNAPSHOT_MISSING",
|
||||
"start",
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} did not publish an initial snapshot for every declared realtime channel.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const startIssue =
|
||||
error instanceof ModuleRuntimeSignal
|
||||
? error.issue
|
||||
: runtimeIssue(
|
||||
"MODULE_START_FAILED",
|
||||
"start",
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} startup failed.`,
|
||||
);
|
||||
: error instanceof ModuleRealtimeError
|
||||
? runtimeIssue(
|
||||
realtimeRuntimeIssueCode(error),
|
||||
"start",
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} realtime publication failed during startup.`,
|
||||
)
|
||||
: runtimeIssue(
|
||||
"MODULE_START_FAILED",
|
||||
"start",
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} startup failed.`,
|
||||
);
|
||||
realtimeRegistry.closeSubscriptions();
|
||||
const rollbackIssues = await stopModules(
|
||||
rollbackCandidates,
|
||||
registry,
|
||||
@@ -727,8 +812,10 @@ async function startModules(
|
||||
services,
|
||||
configurations,
|
||||
storages,
|
||||
realtimeRegistry,
|
||||
options,
|
||||
);
|
||||
realtimeRegistry.deactivateAll();
|
||||
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||
}
|
||||
}
|
||||
@@ -750,13 +837,23 @@ export async function startDiscoveredModuleRuntime(
|
||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||
const httpRoutes = new StagedModuleHttpRoutes();
|
||||
const realtimeRegistry = new StagedModuleRealtimeRegistry();
|
||||
const configurations = timeouts.configurations ?? new Map<string, ModuleConfiguration>();
|
||||
const storages = timeouts.storages ?? new Map<string, ModuleStorage>();
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes, configurations);
|
||||
await startModules(modules, capabilityRegistry, services, configurations, storages, timeouts);
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes, realtimeRegistry, configurations);
|
||||
await startModules(
|
||||
modules,
|
||||
capabilityRegistry,
|
||||
services,
|
||||
configurations,
|
||||
storages,
|
||||
realtimeRegistry,
|
||||
timeouts,
|
||||
);
|
||||
|
||||
const capabilities = capabilityRegistry.publish();
|
||||
const routes = httpRoutes.publish();
|
||||
const realtime = realtimeRegistry.publish();
|
||||
const runningModules = Object.freeze(
|
||||
modules.map((module) =>
|
||||
Object.freeze({
|
||||
@@ -770,9 +867,11 @@ export async function startDiscoveredModuleRuntime(
|
||||
return Object.freeze({
|
||||
capabilities,
|
||||
modules: runningModules,
|
||||
realtime,
|
||||
routes,
|
||||
stop: (): Promise<void> => {
|
||||
stopPromise ??= (async () => {
|
||||
realtime.closeSubscriptions();
|
||||
const issues = await stopModules(
|
||||
modules,
|
||||
capabilityRegistry,
|
||||
@@ -780,8 +879,10 @@ export async function startDiscoveredModuleRuntime(
|
||||
services,
|
||||
configurations,
|
||||
storages,
|
||||
realtimeRegistry,
|
||||
timeouts,
|
||||
);
|
||||
realtimeRegistry.deactivateAll();
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
type PermissionDeclaration,
|
||||
type PermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { DiscoveredModule } from "./discovery.js";
|
||||
|
||||
/**
|
||||
@@ -40,6 +44,54 @@ export interface PublishableModuleUi {
|
||||
readonly permissions: readonly PermissionDeclaration[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed module whose dedicated full-viewport display artifact may be published.
|
||||
*
|
||||
* This is deliberately a separate record from {@link PublishableModuleUi}. A primary UI and a
|
||||
* display UI are two independently declared artifacts with different host roles, and a module may
|
||||
* publish either one without publishing the other. `displayPermission` is the module-owned
|
||||
* permission the manifest places beside `entrypoints.display`; it travels with the exact artifact
|
||||
* identity so a display descriptor cannot accidentally authorize one artifact with another
|
||||
* module's policy. The complete declaration list supports the same browser-safe permission hints
|
||||
* as the primary UI while conferring no authority by itself.
|
||||
*/
|
||||
export interface PublishableModuleDisplay {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly name: string;
|
||||
readonly artifactPath: string;
|
||||
readonly displayPermission: PermissionId;
|
||||
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?"
|
||||
*
|
||||
@@ -60,6 +112,47 @@ export interface ModuleUiPublicationRegistry {
|
||||
* version are all indistinguishable here — each returns `undefined`.
|
||||
*/
|
||||
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
|
||||
/** Every dedicated display artifact, in discovery order. Stable and frozen. */
|
||||
listDisplays(): readonly PublishableModuleDisplay[];
|
||||
/**
|
||||
* The dedicated display record for an exact module id and installed version, or `undefined`.
|
||||
*
|
||||
* This lookup never falls back to `entrypoints.ui`: a primary/dashboard artifact cannot become
|
||||
* a display merely because a caller appends `/display` to its publication URL.
|
||||
*/
|
||||
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
||||
/** Every publishable browser contribution, in discovery order then manifest order. Frozen. */
|
||||
listContributions(): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* Every publishable contribution targeting one capability, in the same stable order.
|
||||
*
|
||||
* This is the lookup a *consumer* module's descriptor is built from, and the reason a consumer can
|
||||
* only ever learn about renderers aimed at a capability it actually provides: the caller passes
|
||||
* its own declared capability id and gets back exactly the contributions for it.
|
||||
*/
|
||||
contributionsForCapability(capabilityId: unknown): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* Every publishable contribution aimed at **any** capability the named module provides.
|
||||
*
|
||||
* This is the one lookup a descriptor for a consuming module is built from. Framing it per module
|
||||
* rather than per capability is what keeps capability ownership inside Core: a caller hands over a
|
||||
* module id and gets that module's own extension points filled in, with no way to ask about
|
||||
* someone else's.
|
||||
*/
|
||||
contributionsForModule(moduleId: unknown): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* The publishable record for an exact contributing module id, installed module version, and
|
||||
* contribution id, or `undefined`.
|
||||
*
|
||||
* All three must match. A contribution that is not installed, one whose module is at a different
|
||||
* version, one whose capability has no compatible provider, and an id that was never declared are
|
||||
* all indistinguishable here — each returns `undefined`.
|
||||
*/
|
||||
resolveContribution(
|
||||
moduleId: unknown,
|
||||
moduleVersion: unknown,
|
||||
contributionId: unknown,
|
||||
): PublishableModuleContribution | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,8 +163,9 @@ export interface ModuleUiPublicationRegistry {
|
||||
* 1. the module is in the **eligible** set the platform started — it survived discovery *and*
|
||||
* the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) —
|
||||
* and
|
||||
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file
|
||||
* inside the module package.
|
||||
* 2. for each role independently, its manifest declares `entrypoints.ui` or
|
||||
* `entrypoints.display`, which discovery resolved to a real compiled file inside the module
|
||||
* package.
|
||||
*
|
||||
* A module directory sitting on disk satisfies neither on its own — it has to have survived
|
||||
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
|
||||
@@ -93,30 +187,137 @@ export function createModuleUiPublicationRegistry(
|
||||
modules: readonly DiscoveredModule[],
|
||||
): ModuleUiPublicationRegistry {
|
||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
||||
const contributions: PublishableModuleContribution[] = [];
|
||||
// Every capability the eligible set actually provides, at the version its provider declares. Built
|
||||
// from the same list everything else here is built from, so a contribution can never be checked
|
||||
// against a provider the platform is not running.
|
||||
const capabilityVersions = new Map<string, string>();
|
||||
for (const module of modules) {
|
||||
for (const capability of module.manifest.capabilities) {
|
||||
capabilityVersions.set(capability.id, capability.version);
|
||||
}
|
||||
}
|
||||
|
||||
for (const module of modules) {
|
||||
for (const contribution of module.contributions) {
|
||||
const capabilityVersion = capabilityVersions.get(contribution.capability);
|
||||
// Fails closed on both halves: an absent provider and an incompatible one are equally
|
||||
// unpublishable, so a range mismatch can never be papered over by mounting anyway.
|
||||
if (
|
||||
capabilityVersion === undefined ||
|
||||
!isVersionCompatible(capabilityVersion, contribution.capabilityRange)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
contributions.push(
|
||||
Object.freeze({
|
||||
artifactPath: contribution.artifactPath,
|
||||
capability: contribution.capability,
|
||||
capabilityVersion,
|
||||
contributionId: contribution.id,
|
||||
moduleId: module.manifest.id,
|
||||
moduleVersion: module.manifest.version,
|
||||
version: contribution.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const module of modules) {
|
||||
const artifactPath = module.entrypoints.ui;
|
||||
if (artifactPath === undefined) {
|
||||
continue;
|
||||
if (artifactPath !== undefined) {
|
||||
byModuleId.set(
|
||||
module.manifest.id,
|
||||
Object.freeze({
|
||||
artifactPath,
|
||||
moduleId: module.manifest.id,
|
||||
name: module.manifest.name,
|
||||
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
||||
// this record can never reach the manifest object graph itself.
|
||||
permissions: Object.freeze([...module.manifest.permissions]),
|
||||
version: module.manifest.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const display = module.entrypoints.display;
|
||||
if (display !== undefined) {
|
||||
displaysByModuleId.set(
|
||||
module.manifest.id,
|
||||
Object.freeze({
|
||||
artifactPath: display.artifactPath,
|
||||
displayPermission: display.permission,
|
||||
moduleId: module.manifest.id,
|
||||
name: module.manifest.name,
|
||||
permissions: Object.freeze([...module.manifest.permissions]),
|
||||
version: module.manifest.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
byModuleId.set(
|
||||
module.manifest.id,
|
||||
Object.freeze({
|
||||
artifactPath,
|
||||
moduleId: module.manifest.id,
|
||||
name: module.manifest.name,
|
||||
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
||||
// this record can never reach the manifest object graph itself.
|
||||
permissions: Object.freeze([...module.manifest.permissions]),
|
||||
version: module.manifest.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const published = Object.freeze([...byModuleId.values()]);
|
||||
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
||||
const publishedContributions = Object.freeze([...contributions]);
|
||||
const contributionsByCapability = new Map<string, PublishableModuleContribution[]>();
|
||||
const contributionsByKey = new Map<string, PublishableModuleContribution>();
|
||||
for (const contribution of publishedContributions) {
|
||||
const forCapability = contributionsByCapability.get(contribution.capability) ?? [];
|
||||
forCapability.push(contribution);
|
||||
contributionsByCapability.set(contribution.capability, forCapability);
|
||||
// Module id and contribution id are both namespaced and unique, so this key cannot collide.
|
||||
contributionsByKey.set(
|
||||
`${contribution.moduleId}\u0000${contribution.contributionId}`,
|
||||
contribution,
|
||||
);
|
||||
}
|
||||
const frozenByCapability = new Map(
|
||||
[...contributionsByCapability].map(([capability, list]) => [capability, Object.freeze(list)]),
|
||||
);
|
||||
const noContributions: readonly PublishableModuleContribution[] = Object.freeze([]);
|
||||
const byConsumingModule = new Map<string, readonly PublishableModuleContribution[]>(
|
||||
modules.map((module) => [
|
||||
module.manifest.id,
|
||||
Object.freeze(
|
||||
module.manifest.capabilities.flatMap(
|
||||
(capability) => contributionsByCapability.get(capability.id) ?? [],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
contributionsForCapability: (
|
||||
capabilityId: unknown,
|
||||
): readonly PublishableModuleContribution[] =>
|
||||
typeof capabilityId === "string"
|
||||
? (frozenByCapability.get(capabilityId) ?? noContributions)
|
||||
: noContributions,
|
||||
contributionsForModule: (moduleId: unknown): readonly PublishableModuleContribution[] =>
|
||||
typeof moduleId === "string"
|
||||
? (byConsumingModule.get(moduleId) ?? noContributions)
|
||||
: noContributions,
|
||||
listContributions: (): readonly PublishableModuleContribution[] => publishedContributions,
|
||||
resolveContribution: (
|
||||
moduleId: unknown,
|
||||
moduleVersion: unknown,
|
||||
contributionId: unknown,
|
||||
): PublishableModuleContribution | undefined => {
|
||||
if (
|
||||
typeof moduleId !== "string" ||
|
||||
typeof moduleVersion !== "string" ||
|
||||
typeof contributionId !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = contributionsByKey.get(`${moduleId}\u0000${contributionId}`);
|
||||
// Exact installed-version equality, exactly as the UI and display lookups require: a request
|
||||
// naming a version the platform does not have installed resolves to nothing at all.
|
||||
return candidate?.moduleVersion === moduleVersion ? candidate : undefined;
|
||||
},
|
||||
list: (): readonly PublishableModuleUi[] => published,
|
||||
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||
if (typeof moduleId !== "string" || typeof version !== "string") {
|
||||
return undefined;
|
||||
@@ -127,5 +328,12 @@ export function createModuleUiPublicationRegistry(
|
||||
// than to a different artifact that happens to exist.
|
||||
return candidate?.version === version ? candidate : undefined;
|
||||
},
|
||||
resolveDisplay: (moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined => {
|
||||
if (typeof moduleId !== "string" || typeof version !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = displaysByModuleId.get(moduleId);
|
||||
return candidate?.version === version ? candidate : undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
createDevicePrincipal,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAuthorizationEvaluator } from "../src/authorization/evaluator.js";
|
||||
|
||||
/**
|
||||
* Device authority, decided by the **same** evaluator that decides everything else.
|
||||
*
|
||||
* The point of these tests is mostly negative: a device must not acquire authority from being a
|
||||
* principal, from being paired, from an `authenticated` baseline, or from platform ownership. The
|
||||
* only thing that ever makes a device allowed is an explicit permission granted to that exact
|
||||
* device (ADR 0036).
|
||||
*/
|
||||
const displayPermission = parsePermissionId("announcements.slideshow.view");
|
||||
const otherPermission = parsePermissionId("calendar.display.view");
|
||||
const deviceId = "3f1c8a4e-1d2b-4c3a-9e5f-6a7b8c9d0e1f";
|
||||
|
||||
function evaluator(options: { readonly devicePermissions?: readonly string[] } = {}) {
|
||||
const granted = new Set(options.devicePermissions ?? []);
|
||||
const hasActivePermission = vi.fn(async (_deviceId: string, permission: string) =>
|
||||
granted.has(permission),
|
||||
);
|
||||
// Deliberately always true: if the evaluator ever consulted these for a device, every assertion
|
||||
// below would flip, which is exactly what must not happen.
|
||||
const isPlatformOwner = vi.fn(async () => true);
|
||||
const hasActiveGrant = vi.fn(async () => true);
|
||||
return {
|
||||
evaluator: createAuthorizationEvaluator({
|
||||
devices: { hasActivePermission } as never,
|
||||
grants: { hasActiveGrant } as never,
|
||||
platformOwners: { isPlatformOwner } as never,
|
||||
}),
|
||||
hasActiveGrant,
|
||||
hasActivePermission,
|
||||
isPlatformOwner,
|
||||
};
|
||||
}
|
||||
|
||||
describe("device principals go through the ordinary evaluator", () => {
|
||||
it("allows a device holding the exact restricted permission", async () => {
|
||||
const harness = evaluator({ devicePermissions: [displayPermission] });
|
||||
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission: displayPermission,
|
||||
principal: createDevicePrincipal(deviceId),
|
||||
}),
|
||||
).resolves.toEqual({ allowed: true });
|
||||
expect(harness.hasActivePermission).toHaveBeenCalledWith(deviceId, displayPermission);
|
||||
});
|
||||
|
||||
it("denies a device any permission it was not granted", async () => {
|
||||
const harness = evaluator({ devicePermissions: [displayPermission] });
|
||||
|
||||
// Pairing a screen for one display is not authority over another module's.
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission: otherPermission,
|
||||
principal: createDevicePrincipal(deviceId),
|
||||
}),
|
||||
).resolves.toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("never satisfies an authenticated baseline, however many permissions it holds", async () => {
|
||||
const harness = evaluator({ devicePermissions: [displayPermission] });
|
||||
|
||||
// "A person is signed in" is not true of a television. Every route that settles for the
|
||||
// `authenticated` baseline — the module UI inventory among them — stays closed.
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "authenticated",
|
||||
permission: displayPermission,
|
||||
principal: createDevicePrincipal(deviceId),
|
||||
}),
|
||||
).resolves.toEqual({ allowed: false });
|
||||
expect(harness.hasActivePermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never consults platform ownership or user grants for a device", async () => {
|
||||
const harness = evaluator();
|
||||
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission: displayPermission,
|
||||
principal: createDevicePrincipal(deviceId),
|
||||
}),
|
||||
).resolves.toEqual({ allowed: false });
|
||||
|
||||
// Both ports would have answered "yes". A device is denied anyway, because neither is a path a
|
||||
// device can reach — there is no device that is a platform owner.
|
||||
expect(harness.isPlatformOwner).not.toHaveBeenCalled();
|
||||
expect(harness.hasActiveGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies every device when the deployment composed no device support at all", async () => {
|
||||
const withoutDevices = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async () => true } as never,
|
||||
platformOwners: { isPlatformOwner: async () => true } as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
withoutDevices.authorize({
|
||||
baseline: "restricted",
|
||||
permission: displayPermission,
|
||||
principal: createDevicePrincipal(deviceId),
|
||||
}),
|
||||
).resolves.toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("denies a scoped check for a device rather than treating it as unscoped", async () => {
|
||||
const harness = evaluator({ devicePermissions: [displayPermission] });
|
||||
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission: displayPermission,
|
||||
principal: createDevicePrincipal(deviceId),
|
||||
scope: "event-1",
|
||||
}),
|
||||
).resolves.toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("still allows a device through a public baseline, like any other principal", async () => {
|
||||
const harness = evaluator();
|
||||
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "public",
|
||||
permission: displayPermission,
|
||||
principal: createDevicePrincipal(deviceId),
|
||||
}),
|
||||
).resolves.toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("leaves human and anonymous decisions exactly as they were", async () => {
|
||||
const harness = evaluator();
|
||||
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission: displayPermission,
|
||||
principal: createAuthenticatedPrincipal("user-1"),
|
||||
}),
|
||||
).resolves.toEqual({ allowed: true });
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission: displayPermission,
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
}),
|
||||
).resolves.toEqual({ allowed: false });
|
||||
await expect(
|
||||
harness.evaluator.authorize({
|
||||
baseline: "authenticated",
|
||||
permission: displayPermission,
|
||||
principal: createAuthenticatedPrincipal("user-1"),
|
||||
}),
|
||||
).resolves.toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("propagates a device lookup failure rather than reporting it as denied", async () => {
|
||||
const failing = createAuthorizationEvaluator({
|
||||
devices: {
|
||||
hasActivePermission: async () => {
|
||||
throw new Error("database unavailable");
|
||||
},
|
||||
} as never,
|
||||
grants: { hasActiveGrant: async () => false } as never,
|
||||
platformOwners: { isPlatformOwner: async () => false } as never,
|
||||
});
|
||||
|
||||
// A caller must never be told "no permission" when the true answer is "could not be
|
||||
// determined", exactly as for a human.
|
||||
await expect(
|
||||
failing.authorize({
|
||||
baseline: "restricted",
|
||||
permission: displayPermission,
|
||||
principal: createDevicePrincipal(deviceId),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
@@ -132,13 +133,14 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||
});
|
||||
|
||||
it("contains exactly these five permissions and no others", () => {
|
||||
it("contains exactly these six permissions and no others", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -171,6 +173,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
description: "Read and change an installed module's own configuration values.",
|
||||
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
},
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
id: CORE_DEVICES_MANAGE,
|
||||
},
|
||||
]);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||
|
||||
@@ -176,6 +176,8 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -228,6 +230,11 @@ describe.sequential("live Core authorization store", () => {
|
||||
"ekklesia_core_authorization_audit",
|
||||
"ekklesia_core_dashboard_group",
|
||||
"ekklesia_core_dashboard_placement",
|
||||
// Device identity is Core's, and deliberately its own tables rather than rows smuggled into
|
||||
// the user or grant tables (ADR 0036).
|
||||
"ekklesia_core_device",
|
||||
"ekklesia_core_device_pairing_request",
|
||||
"ekklesia_core_device_permission",
|
||||
"ekklesia_core_grant",
|
||||
"ekklesia_core_migrations",
|
||||
"ekklesia_core_module_configuration",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
describe("create-initial-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("reads no environment variable for the password", async () => {
|
||||
// The password is prompted for or read from standard input; it is deliberately never taken from
|
||||
// the environment, so nothing can make it permanent deployment configuration. Asserted against
|
||||
// the source rather than behaviour, because the absence of a lookup is what matters.
|
||||
const source = await import("node:fs/promises").then((fs) =>
|
||||
fs.readFile(new URL("../src/cli/create-initial-owner.ts", import.meta.url), "utf8"),
|
||||
);
|
||||
|
||||
const environmentReads = [...source.matchAll(/process\.env\.([A-Z_]+)/gu)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
expect(environmentReads).toEqual(["DATABASE_URL"]);
|
||||
// No dynamic lookup either, which a named-property scan alone would miss.
|
||||
expect(source).not.toMatch(/process\.env\[/u);
|
||||
// And a password supplied on the command line is refused rather than quietly honoured.
|
||||
expect(source).toContain("--password is not supported");
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
: {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -175,6 +175,8 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import type { ModuleManifest } from "@ekklesia/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPermissionRegistry } from "../src/authorization/index.js";
|
||||
import { createDoctorOptions } from "../src/cli/doctor.js";
|
||||
import {
|
||||
type DoctorCheck,
|
||||
type DoctorDependencies,
|
||||
diagnoseEkklesia,
|
||||
formatDoctorReport,
|
||||
} from "../src/diagnostics/index.js";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleMigrationAggregate,
|
||||
ModuleSchemaAggregate,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
/**
|
||||
* The bug this suite exists for was operational, not theoretical: `pnpm doctor` built its own
|
||||
* `DoctorOptions` and simply never included object storage or module secrets, so a correctly
|
||||
* configured installation was told its storage was "not configured" and every module requiring a
|
||||
* bucket was reported blocked — while `startApi`, from the identical environment, ran them.
|
||||
*
|
||||
* These tests therefore go through `createDoctorOptions`, the exact function the CLI calls, with an
|
||||
* environment fixture rather than a hand-built options object. A helper-only test would have passed
|
||||
* happily on the broken code.
|
||||
*/
|
||||
|
||||
const databaseUrl = "postgres://[email protected]:5432/ekklesia";
|
||||
const accessKey = "AKIAEXAMPLEACCESSID";
|
||||
const secretKey = "sk-example-secret-material";
|
||||
const smtpPassword = "sup3r-secret-relay-password";
|
||||
const identitySecret = "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW";
|
||||
const moduleSecret = "module-secret-material";
|
||||
|
||||
const metadata = Object.freeze({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
platformVersion: "0.1.0",
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
});
|
||||
|
||||
function environment(
|
||||
overrides: Readonly<Record<string, string | undefined>> = {},
|
||||
): Readonly<Record<string, string | undefined>> {
|
||||
return {
|
||||
DATABASE_URL: databaseUrl,
|
||||
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
|
||||
EKKLESIA_IDENTITY_SECRET: identitySecret,
|
||||
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
|
||||
NODE_ENV: "production",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const storageEnvironment = Object.freeze({
|
||||
S3_ACCESS_KEY: accessKey,
|
||||
S3_ENDPOINT: "https://storage.example.org",
|
||||
S3_SECRET_KEY: secretKey,
|
||||
});
|
||||
|
||||
const mailEnvironment = Object.freeze({
|
||||
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
||||
EKKLESIA_SMTP_SECURITY: "starttls",
|
||||
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
||||
});
|
||||
|
||||
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||
manifest: {
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: [],
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: {} },
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
version: "0.1.0",
|
||||
...overrides,
|
||||
} as unknown as ModuleManifest,
|
||||
manifestPath: `/unpublished/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const storingModule = discoveredModule("announcements", {
|
||||
storage: { buckets: [{ id: "announcement-assets" }] },
|
||||
});
|
||||
|
||||
const secretRequiringModule = discoveredModule("announcements", {
|
||||
configuration: [{ key: "storageCredential", required: true, type: "secret" }],
|
||||
});
|
||||
|
||||
function schemaAggregate(): ModuleSchemaAggregate {
|
||||
return Object.freeze({
|
||||
discoveredModules: Object.freeze([]),
|
||||
drizzleSchema: Object.freeze({}),
|
||||
modules: Object.freeze([]),
|
||||
tables: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
function migrationAggregate(): ModuleMigrationAggregate {
|
||||
return Object.freeze({
|
||||
discoveredModules: Object.freeze([]),
|
||||
migrations: Object.freeze([]),
|
||||
modules: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
|
||||
return {
|
||||
composePermissions: vi.fn((modules: readonly DiscoveredModule[]) =>
|
||||
createPermissionRegistry(
|
||||
modules.flatMap((discovered) => [...discovered.manifest.permissions]),
|
||||
),
|
||||
),
|
||||
createMigrationDatabase: vi.fn(() => {
|
||||
throw new Error("not used");
|
||||
}),
|
||||
discoverMigrations: vi.fn(async () => migrationAggregate()),
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => {
|
||||
throw new Error("not used");
|
||||
}),
|
||||
probeMail: vi.fn(async () => undefined),
|
||||
probeStorage: vi.fn(async () => undefined),
|
||||
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
runtimeSatisfies: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function diagnose(
|
||||
env: Readonly<Record<string, string | undefined>>,
|
||||
services: DoctorDependencies = dependencies(),
|
||||
): Promise<{ readonly report: Awaited<ReturnType<typeof diagnoseEkklesia>> }> {
|
||||
return { report: await diagnoseEkklesia(createDoctorOptions(env, metadata), services) };
|
||||
}
|
||||
|
||||
function check(
|
||||
report: Awaited<ReturnType<typeof diagnoseEkklesia>>,
|
||||
id: DoctorCheck["id"],
|
||||
): DoctorCheck {
|
||||
const found = report.checks.find((candidate) => candidate.id === id);
|
||||
if (!found) {
|
||||
throw new Error(`Missing test check ${id}.`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("Doctor host-configuration parity — object storage", () => {
|
||||
it("recognizes configured object storage from the deployment environment", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const { report } = await diagnose(environment(storageEnvironment), services);
|
||||
const integration = check(report, "object-storage.integration");
|
||||
|
||||
// The regression itself: before this pass the CLI passed no storage at all and this read
|
||||
// "skipped · Object storage not configured" from exactly this environment.
|
||||
expect(integration.status).toBe("pass");
|
||||
expect(integration.summary).toBe("Object storage reachable");
|
||||
expect(services.probeStorage).toHaveBeenCalledWith({
|
||||
accessKey,
|
||||
bucketPrefix: "ekklesia",
|
||||
deliveryEndpoint: "https://storage.example.org",
|
||||
endpoint: "https://storage.example.org",
|
||||
forcePathStyle: true,
|
||||
region: "us-east-1",
|
||||
secretKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("still reports genuinely absent storage as absent", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const { report } = await diagnose(environment(), services);
|
||||
const integration = check(report, "object-storage.integration");
|
||||
|
||||
expect(integration.status).toBe("skipped");
|
||||
expect(integration.summary).toBe("Object storage not configured");
|
||||
expect(services.probeStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a half-configured provider as a configuration failure, naming no value", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const { report } = await diagnose(
|
||||
environment({ S3_ENDPOINT: "https://storage.example.org" }),
|
||||
services,
|
||||
);
|
||||
const integration = check(report, "object-storage.integration");
|
||||
|
||||
// `startApi` refuses to start on this input, so the deployment is genuinely not ready — this is
|
||||
// a failure rather than the warning an unreachable-but-valid provider gets.
|
||||
expect(integration.status).toBe("failure");
|
||||
expect(integration.details[0]).toBe(
|
||||
"The object-storage settings were rejected: STORAGE_ACCESS_KEY_MISSING.",
|
||||
);
|
||||
expect(report.result).toBe("not-ready");
|
||||
// Nothing was connected to: a malformed configuration must not produce network traffic.
|
||||
expect(services.probeStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a malformed endpoint safely rather than crashing the command", async () => {
|
||||
const { report } = await diagnose(
|
||||
environment({ ...storageEnvironment, S3_ENDPOINT: "not-a-url" }),
|
||||
);
|
||||
const integration = check(report, "object-storage.integration");
|
||||
|
||||
expect(integration.status).toBe("failure");
|
||||
expect(integration.details[0]).toBe(
|
||||
"The object-storage settings were rejected: STORAGE_ENDPOINT_INVALID.",
|
||||
);
|
||||
expect(JSON.stringify(integration)).not.toContain("not-a-url");
|
||||
});
|
||||
|
||||
it("refuses a plaintext production endpoint exactly as startup would", async () => {
|
||||
const { report } = await diagnose(
|
||||
environment({ ...storageEnvironment, S3_ENDPOINT: "http://storage.example.org" }),
|
||||
);
|
||||
|
||||
expect(check(report, "object-storage.integration").details[0]).toBe(
|
||||
"The object-storage settings were rejected: STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION.",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the same region and bucket-prefix defaults the runtime applies", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
await diagnose(environment(storageEnvironment), services);
|
||||
|
||||
expect(services.probeStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bucketPrefix: "ekklesia",
|
||||
forcePathStyle: true,
|
||||
region: "us-east-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the browser delivery origin to the endpoint, and flags a separate one", async () => {
|
||||
const { report: shared } = await diagnose(environment(storageEnvironment));
|
||||
expect(check(shared, "object-storage.integration").details).toHaveLength(1);
|
||||
|
||||
const { report: separate } = await diagnose(
|
||||
environment({ ...storageEnvironment, S3_DELIVERY_ENDPOINT: "https://files.example.org" }),
|
||||
);
|
||||
const integration = check(separate, "object-storage.integration");
|
||||
expect(integration.details[0]).toContain("browser delivery https://files.example.org");
|
||||
expect(integration.details[1]).toContain("was not probed");
|
||||
});
|
||||
|
||||
it("binds a module's declared bucket instead of reporting it blocked", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
|
||||
});
|
||||
|
||||
const { report } = await diagnose(environment(storageEnvironment), services);
|
||||
|
||||
// The operator-visible consequence of the bug: this module was reported blocked by a Doctor run
|
||||
// against the very environment that ran it.
|
||||
expect(check(report, "object-storage.bindings").status).toBe("pass");
|
||||
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
|
||||
});
|
||||
|
||||
it("still reports a bucket as unbound when no provider is configured", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
|
||||
});
|
||||
|
||||
const { report } = await diagnose(environment(), services);
|
||||
|
||||
expect(check(report, "object-storage.bindings").status).toBe("failure");
|
||||
expect(check(report, "modules.lifecycle").details).toContain(
|
||||
"announcements 0.1.0 · blocked · storage_unavailable · announcement-assets",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a malformed provider as unusable when deciding module eligibility", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
|
||||
});
|
||||
|
||||
const { report } = await diagnose(
|
||||
environment({ S3_ENDPOINT: "https://storage.example.org" }),
|
||||
services,
|
||||
);
|
||||
|
||||
// A provider that cannot be constructed cannot bind a bucket, and saying otherwise would report
|
||||
// a module as runnable that startup would refuse to start at all.
|
||||
expect(check(report, "object-storage.bindings").status).toBe("failure");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Doctor host-configuration parity — module secrets", () => {
|
||||
it("sees a secret the deployment actually set", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])),
|
||||
});
|
||||
|
||||
const { report } = await diagnose(
|
||||
environment({
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret,
|
||||
}),
|
||||
services,
|
||||
);
|
||||
const configuration = check(report, "modules.configuration");
|
||||
|
||||
// Same class of bug as storage: the CLI never passed a secret source, so a configured secret
|
||||
// was always reported missing.
|
||||
expect(configuration.status).toBe("pass");
|
||||
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
|
||||
expect(JSON.stringify(report)).not.toContain(moduleSecret);
|
||||
});
|
||||
|
||||
it("still reports a genuinely unset secret as missing", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])),
|
||||
});
|
||||
|
||||
const { report } = await diagnose(environment(), services);
|
||||
|
||||
expect(check(report, "modules.configuration").status).toBe("warning");
|
||||
expect(check(report, "modules.lifecycle").details).toContain(
|
||||
"announcements 0.1.0 · blocked · secret_missing · storageCredential",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Doctor host-configuration parity — outbound email regression", () => {
|
||||
it("leaves an installation with no SMTP host reported as complete", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const { report } = await diagnose(environment(), services);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("skipped");
|
||||
expect(mailCheck.summary).toBe("Outbound email not configured");
|
||||
expect(services.probeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a configured relay reported as reachable, without its password", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const { report } = await diagnose(environment(mailEnvironment), services);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("pass");
|
||||
expect(services.probeMail).toHaveBeenCalledTimes(1);
|
||||
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 () => {
|
||||
const { report } = await diagnose(
|
||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }),
|
||||
);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("failure");
|
||||
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_PORT_INVALID.");
|
||||
expect(JSON.stringify(mailCheck)).not.toContain("70000");
|
||||
});
|
||||
|
||||
it("still refuses a plaintext production relay", async () => {
|
||||
const { report } = await diagnose(
|
||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_SECURITY: "plaintext" }),
|
||||
);
|
||||
|
||||
expect(check(report, "outbound-email.configuration").details[0]).toBe(
|
||||
"The SMTP settings were rejected: MAIL_SECURITY_INSECURE_IN_PRODUCTION.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Doctor host-configuration parity — sanitization", () => {
|
||||
it("prints no secret material anywhere in a fully configured report", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
|
||||
});
|
||||
|
||||
const { report } = await diagnose(
|
||||
environment({
|
||||
...mailEnvironment,
|
||||
...storageEnvironment,
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret,
|
||||
}),
|
||||
services,
|
||||
);
|
||||
const rendered = formatDoctorReport(report).join("\n");
|
||||
|
||||
for (const secret of [accessKey, secretKey, smtpPassword, identitySecret, moduleSecret]) {
|
||||
expect(rendered).not.toContain(secret);
|
||||
expect(JSON.stringify(report)).not.toContain(secret);
|
||||
}
|
||||
// The infrastructure host names are deliberately still shown: they carry no credential and are
|
||||
// what an operator needs in order to act on the report.
|
||||
expect(rendered).toContain("https://storage.example.org");
|
||||
expect(rendered).toContain("smtp.example.org");
|
||||
});
|
||||
|
||||
it("never serializes the environment object wholesale", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const { report } = await diagnose(
|
||||
environment({ ...storageEnvironment, TOTALLY_UNRELATED_SECRET: "unrelated-value" }),
|
||||
services,
|
||||
);
|
||||
|
||||
expect(JSON.stringify(report)).not.toContain("unrelated-value");
|
||||
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type DoctorDependencies,
|
||||
diagnoseEkklesia,
|
||||
formatDoctorReport,
|
||||
RENDERED_DOCTOR_CHECK_IDS,
|
||||
} from "../src/diagnostics/index.js";
|
||||
import type { MigrationDatabase } from "../src/migrations/index.js";
|
||||
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
|
||||
@@ -47,6 +48,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
||||
planMigrations: async () => {
|
||||
throw new Error("database must not be planned");
|
||||
},
|
||||
probeMail: async () => {
|
||||
throw new Error("mail must not be probed");
|
||||
},
|
||||
probeStorage: async () => {
|
||||
throw new Error("storage must not be probed");
|
||||
},
|
||||
@@ -87,7 +91,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
5 permissions registered
|
||||
6 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
@@ -113,6 +117,7 @@ Identity
|
||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||
✓ Direct client-address mode
|
||||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||
Checked: the environment Doctor runs with. In the reference deployment \`migrate\` is given the API's client-address values; a running API's own value is shown by \`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE\`.
|
||||
|
||||
Object storage
|
||||
○ Object storage not configured
|
||||
@@ -120,9 +125,37 @@ Object storage
|
||||
✓ No module requires object storage
|
||||
0 modules declare a logical storage bucket.
|
||||
|
||||
Outbound email
|
||||
○ Outbound email not configured
|
||||
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`);
|
||||
});
|
||||
|
||||
it("renders every check the report contains, so none can silently affect the result", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
},
|
||||
noConfigurationDependencies(),
|
||||
);
|
||||
|
||||
// `outbound-email.configuration` was computed, counted toward the overall result, and never
|
||||
// printed — so a rejected SMTP setting turned an operator's report NOT READY with nothing on
|
||||
// screen saying why. Asserting the two sets against each other is what keeps a future check
|
||||
// from repeating it.
|
||||
expect([...RENDERED_DOCTOR_CHECK_IDS].sort()).toEqual(
|
||||
report.checks.map((entry) => entry.id).sort(),
|
||||
);
|
||||
expect(new Set(RENDERED_DOCTOR_CHECK_IDS).size).toBe(RENDERED_DOCTOR_CHECK_IDS.length);
|
||||
});
|
||||
|
||||
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
|
||||
+286
-5
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
|
||||
|
||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||
manifest: {
|
||||
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -138,6 +139,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => plan()),
|
||||
probeMail: vi.fn(async () => undefined),
|
||||
probeStorage: vi.fn(async () => undefined),
|
||||
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
@@ -187,6 +189,99 @@ function planEntry(
|
||||
});
|
||||
}
|
||||
|
||||
describe("Ekklesia Doctor outbound email reporting", () => {
|
||||
const mailConfiguration = {
|
||||
fromAddress: "[email protected]",
|
||||
fromName: "Ekklesia",
|
||||
host: "smtp.example.org",
|
||||
password: "sup3r-secret-relay-password",
|
||||
security: "starttls",
|
||||
username: "[email protected]",
|
||||
};
|
||||
|
||||
it("reports an installation with no SMTP host as complete rather than degraded", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("skipped");
|
||||
expect(mailCheck.summary).toBe("Outbound email not configured");
|
||||
// Nothing sends email yet, so an absent provider must not make a deployment read as broken.
|
||||
expect(report.result).not.toBe("not-ready");
|
||||
expect(services.probeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a reachable provider without ever naming a credential", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("pass");
|
||||
const rendered = mailCheck.details.join(" ");
|
||||
expect(rendered).toContain("smtp.example.org:587");
|
||||
expect(rendered).toContain("starttls");
|
||||
expect(rendered).toContain("authenticated");
|
||||
expect(rendered).not.toContain("sup3r-secret-relay-password");
|
||||
expect(rendered).not.toContain("[email protected]");
|
||||
// Honest about what a connection proves: it did not send anything, and deliverability is not
|
||||
// something this check can establish.
|
||||
expect(rendered).toMatch(/did not|without sending/iu);
|
||||
});
|
||||
|
||||
it("warns rather than failing when a configured relay does not answer", async () => {
|
||||
const services = dependencies({
|
||||
probeMail: vi.fn(async () => {
|
||||
throw new Error("connection refused to smtp.example.org with sup3r-secret-relay-password");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
// A warning, never a failure: the configuration is valid and nothing depends on mail yet.
|
||||
expect(mailCheck.status).toBe("warning");
|
||||
expect(report.result).not.toBe("not-ready");
|
||||
// The thrown value carried a credential; none of it reaches the report.
|
||||
expect(mailCheck.details.join(" ")).not.toContain("sup3r-secret-relay-password");
|
||||
});
|
||||
|
||||
it("fails a malformed configuration with a sanitized code and probes nothing", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(), mailConfiguration: { ...mailConfiguration, port: "not-a-port" } },
|
||||
services,
|
||||
);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("failure");
|
||||
const rendered = mailCheck.details.join(" ");
|
||||
expect(rendered).toContain("MAIL_PORT_INVALID");
|
||||
expect(rendered).not.toContain("sup3r-secret-relay-password");
|
||||
// A configuration this broken is never connected to.
|
||||
expect(services.probeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses plaintext SMTP when the deployment validates as production", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
databaseEnvironment: "production",
|
||||
mailConfiguration: { ...mailConfiguration, security: "plaintext" },
|
||||
},
|
||||
services,
|
||||
);
|
||||
|
||||
expect(check(report, "outbound-email.configuration").details.join(" ")).toContain(
|
||||
"MAIL_SECURITY_INSECURE_IN_PRODUCTION",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia Doctor diagnostics", () => {
|
||||
it("publishes a healthy deterministic static report without database configuration", async () => {
|
||||
const people = discoveredModule("people");
|
||||
@@ -219,6 +314,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"identity.client-address",
|
||||
"object-storage.integration",
|
||||
"object-storage.bindings",
|
||||
"outbound-email.configuration",
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||
@@ -609,6 +705,88 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
||||
});
|
||||
|
||||
describe("Dashboard and API topology (ADR 0045)", () => {
|
||||
const secret = "correct-horse-battery-staple-0123456789";
|
||||
async function identityCheck(identity: {
|
||||
readonly baseUrl: string;
|
||||
readonly dashboardOrigin?: string;
|
||||
readonly environment?: "development" | "production";
|
||||
readonly trustedOrigins: readonly string[];
|
||||
}) {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: { environment: "production", secret, ...identity },
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
return { check: check(report, "identity.configuration"), report };
|
||||
}
|
||||
|
||||
it("reports the same-origin topology when no Dashboard origin is set", async () => {
|
||||
const { check: identity } = await identityCheck({
|
||||
baseUrl: "https://ekklesia.example.test",
|
||||
trustedOrigins: ["https://ekklesia.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details).toContain(
|
||||
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the split topology for sibling hostnames without printing either", async () => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl: "https://api.example.test",
|
||||
dashboardOrigin: "https://dash.example.test",
|
||||
trustedOrigins: ["https://dash.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details).toContain(
|
||||
"Topology: split (the Dashboard and the API are on separate origins).",
|
||||
);
|
||||
expect(JSON.stringify(report)).not.toContain("dash.example.test");
|
||||
expect(JSON.stringify(report)).not.toContain("api.example.test");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["different registrable domains", "https://dash.example.test", "https://api.example.org"],
|
||||
["two IP addresses", "https://10.0.0.2", "https://10.0.0.1"],
|
||||
["a parent that is only a top-level domain", "https://dash.test", "https://api.test"],
|
||||
])("warns, without failing, when split hostnames may not share a site: %s", async (_case, dashboardOrigin, baseUrl) => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl,
|
||||
dashboardOrigin,
|
||||
trustedOrigins: [dashboardOrigin],
|
||||
});
|
||||
expect(identity.status).toBe("warning");
|
||||
expect(identity.details.join("\n")).toContain("not siblings under one parent domain");
|
||||
expect(doctorExitCode(report)).toBe(0);
|
||||
});
|
||||
|
||||
it("treats another port on the same host as the same site (split development)", async () => {
|
||||
const { check: identity } = await identityCheck({
|
||||
baseUrl: "http://localhost:3000",
|
||||
dashboardOrigin: "http://localhost:5173",
|
||||
environment: "development",
|
||||
trustedOrigins: ["http://localhost:5173"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails, as the API's startup would, for a Dashboard origin the API does not trust", async () => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl: "https://api.example.test",
|
||||
dashboardOrigin: "https://dash.example.test",
|
||||
trustedOrigins: ["https://other.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("failure");
|
||||
expect(identity.details.some((detail) => detail.includes("DASHBOARD_ORIGIN_UNTRUSTED"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(doctorExitCode(report)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
||||
const hostileSecret = "s3cr3t-do-not-log";
|
||||
const report = await diagnoseEkklesia(
|
||||
@@ -780,6 +958,21 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
});
|
||||
|
||||
it("says whose client-address configuration it checked: this process's, not a running API's", async () => {
|
||||
for (const clientAddress of [
|
||||
undefined,
|
||||
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["10.0.0.1"] },
|
||||
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["bad"] },
|
||||
]) {
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(), ...(clientAddress ? { clientAddress } : {}) },
|
||||
dependencies(),
|
||||
);
|
||||
const details = check(report, "identity.client-address").details;
|
||||
expect(details.some((detail) => detail.includes("environment Doctor runs with"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
@@ -1283,7 +1476,7 @@ describe("Ekklesia Doctor — object storage", () => {
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(databaseUrl), storage: storageConfig },
|
||||
{ ...options(databaseUrl), storageConfiguration: storageConfig },
|
||||
services,
|
||||
);
|
||||
const bindings = check(report, "object-storage.bindings");
|
||||
@@ -1303,7 +1496,7 @@ describe("Ekklesia Doctor — object storage", () => {
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(databaseUrl), storage: storageConfig },
|
||||
{ ...options(databaseUrl), storageConfiguration: storageConfig },
|
||||
services,
|
||||
);
|
||||
const integration = check(report, "object-storage.integration");
|
||||
@@ -1325,7 +1518,7 @@ describe("Ekklesia Doctor — object storage", () => {
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(databaseUrl), storage: storageConfig },
|
||||
{ ...options(databaseUrl), storageConfiguration: storageConfig },
|
||||
services,
|
||||
);
|
||||
const integration = check(report, "object-storage.integration");
|
||||
@@ -1353,7 +1546,7 @@ describe("Ekklesia Doctor — object storage", () => {
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(databaseUrl), storage: storageConfig },
|
||||
{ ...options(databaseUrl), storageConfiguration: storageConfig },
|
||||
services,
|
||||
);
|
||||
|
||||
@@ -1376,3 +1569,91 @@ describe("Ekklesia Doctor — object storage", () => {
|
||||
expect(check(report, "object-storage.bindings").status).toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A browser contribution whose target capability nobody provides is otherwise silent: the manifest
|
||||
* is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041).
|
||||
* Doctor names it.
|
||||
*/
|
||||
describe("Doctor reports declared browser contributions", () => {
|
||||
function contributing(
|
||||
id: string,
|
||||
contribution: { capability: string; capabilityRange: string },
|
||||
): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
contributions: [
|
||||
{
|
||||
artifactPath: `/unpublished/${id}/contributions/slide.js`,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: `${id}.slide`,
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function providing(id: string, capability: { id: string; version: string }): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"],
|
||||
};
|
||||
}
|
||||
|
||||
async function detailsFor(modules: readonly DiscoveredModule[]): Promise<readonly string[]> {
|
||||
const report = await diagnoseEkklesia(
|
||||
options(),
|
||||
dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([...modules])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate(modules)),
|
||||
}),
|
||||
);
|
||||
return check(report, "modules.discovery").details;
|
||||
}
|
||||
|
||||
it("says nothing extra for a module that contributes nothing", async () => {
|
||||
expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]);
|
||||
});
|
||||
|
||||
it("reports a contribution whose provider satisfies its declared range", async () => {
|
||||
const details = await detailsFor([
|
||||
providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }),
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(details).toEqual([
|
||||
"signage 0.1.0",
|
||||
"announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => {
|
||||
expect(
|
||||
await detailsFor([
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]),
|
||||
).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]);
|
||||
|
||||
expect(
|
||||
await detailsFor([
|
||||
providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }),
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]),
|
||||
).toEqual([
|
||||
"signage 0.1.0",
|
||||
"announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user