Compare commits
192
Commits
c38b572778
..
v0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e01679fcb | ||
|
|
227c92e35d | ||
|
|
505c9e8c0a | ||
|
|
4be046c8d0 | ||
|
|
c5a6cdaf6a | ||
|
|
c4fded6ecf | ||
|
|
9ad2d5b564 | ||
|
|
98d93cc12d | ||
|
|
e083c44e05 | ||
|
|
4919db6168 | ||
|
|
6e76293620 | ||
|
|
3b54a7f023 | ||
|
|
afaf3a853c | ||
|
|
1788d17f0a | ||
|
|
1caffc5f26 | ||
|
|
debb8df3ce | ||
|
|
98e59d4ba0 | ||
|
|
22404ea2d4 | ||
|
|
486356a01e | ||
|
|
1f30c86d4f | ||
|
|
d3c62e63bf | ||
|
|
57df2e15f2 | ||
|
|
4f1b1d68c7 | ||
|
|
9debe4e891 | ||
|
|
7ea2d7883f | ||
|
|
aaa7ebfd84 | ||
|
|
89d5d0f2da | ||
|
|
9426c83109 | ||
|
|
47e64c6f39 | ||
|
|
5803757153 | ||
|
|
44d385cd7f | ||
|
|
f53506b3f0 | ||
|
|
951453c735 | ||
|
|
99f145a80c | ||
|
|
104a5145c8 | ||
|
|
796ad4f16e | ||
|
|
b1e3244c1d | ||
|
|
85fae9e07e | ||
|
|
30f61094e6 | ||
|
|
1c7755d320 | ||
|
|
42acdeb9e8 | ||
|
|
70854d234c | ||
|
|
20ad1ae208 | ||
|
|
49b4d4165d | ||
|
|
a1cdcb3ad1 | ||
|
|
859675c3c7 | ||
|
|
4a3958c616 | ||
|
|
317a4ce410 | ||
|
|
6db50516b7 | ||
|
|
d43031997b | ||
|
|
11e2495484 | ||
|
|
759872834d | ||
|
|
9ddb5e2796 | ||
|
|
52a665502f | ||
|
|
9a328f647e | ||
|
|
22caa1dceb | ||
|
|
27d85e0162 | ||
|
|
35ef31df9f | ||
|
|
45d6506c10 | ||
|
|
1119cc5fdf | ||
|
|
fbf8f6edac | ||
|
|
f122e910fc | ||
|
|
e36b70d8d4 | ||
|
|
4d28a82937 | ||
|
|
cefdde36a2 | ||
|
|
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 | ||
|
|
fcc531a39c | ||
|
|
7ff063b87a | ||
|
|
9a3b8dab8f | ||
|
|
af4a8009fb | ||
|
|
b9ca0d560d | ||
|
|
527239f6de | ||
|
|
baa689b2a7 | ||
|
|
fdaad58848 | ||
|
|
7729c99e24 | ||
|
|
db67cf7700 | ||
|
|
bd34c9c714 | ||
|
|
ac5b2b7f6c | ||
|
|
616c1d4834 | ||
|
|
7a76ff6f26 | ||
|
|
a02deb9433 | ||
|
|
82cbeef437 | ||
|
|
3f9b5e3cb0 | ||
|
|
ff159a04dc | ||
|
|
44e676f0df | ||
|
|
5d75d910fd | ||
|
|
1e5866504e | ||
|
|
f9c1108527 | ||
|
|
8d651c427d | ||
|
|
b5aa4371b6 | ||
|
|
44ba259f58 | ||
|
|
94d26cffa0 | ||
|
|
288c088377 | ||
|
|
27088fc054 | ||
|
|
a2db774b8c | ||
|
|
cda841f8c7 | ||
|
|
07a3c61083 | ||
|
|
f7621a599f | ||
|
|
51f4075fd6 | ||
|
|
6912680113 | ||
|
|
74e8840f0d | ||
|
|
2983789779 | ||
|
|
490b85b07c | ||
|
|
800ecfdb92 | ||
|
|
4a34a8203c | ||
|
|
5c589d8fec | ||
|
|
532ce3803b | ||
|
|
6b6b77c105 | ||
|
|
7a0f624fc3 | ||
|
|
3d6c85b0f8 | ||
|
|
1161f5a076 | ||
|
|
d3c6bd73d3 | ||
|
|
cafd513019 | ||
|
|
5e09330001 |
@@ -0,0 +1,30 @@
|
||||
# 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.
|
||||
|
||||
# Installed modules never enter the platform image (ADR 0048): an installation keeps them in its own
|
||||
# module storage, mounted at run time. Excluding the checkout's `modules/` is what makes a build
|
||||
# identical whether or not modules happen to be present in the source tree.
|
||||
/modules
|
||||
|
||||
# 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
|
||||
+103
-1
@@ -1,14 +1,116 @@
|
||||
# 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
|
||||
# (EKKLESIA_DATABASE_TLS_MODE defaults to verify-full) and rejects loopback/http identity origins.
|
||||
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
|
||||
NODE_ENV=development
|
||||
|
||||
# Installed modules (ADR 0048). Unset, the checkout's own modules/ is used, as module development
|
||||
# and the platform's tests expect. Set, it must be an absolute path to an existing directory;
|
||||
# anything else stops the API and every module command with a sanitized error. The production
|
||||
# image sets /var/lib/ekklesia/modules.
|
||||
# EKKLESIA_MODULES_DIR=/absolute/path/to/modules
|
||||
|
||||
POSTGRES_DB=ekklesia
|
||||
POSTGRES_USER=ekklesia
|
||||
POSTGRES_PASSWORD=ekklesia-development-only
|
||||
POSTGRES_PORT=5432
|
||||
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
|
||||
|
||||
# Ekklesia's object-storage provider (ADR 0030). Absent S3_ENDPOINT means this installation has no
|
||||
# object storage, which is a complete deployment: only a module that declares a logical bucket in its
|
||||
# manifest is affected, and it is reported blocked rather than breaking anything. Production requires
|
||||
# https, exactly as the database transport requires verified TLS.
|
||||
#
|
||||
# These are host infrastructure. No module ever sees them: a module names a logical bucket and the
|
||||
# host resolves the binding.
|
||||
S3_ACCESS_KEY=ekklesia-development
|
||||
S3_SECRET_KEY=ekklesia-development-secret
|
||||
S3_BUCKET=ekklesia-development
|
||||
S3_ENDPOINT=http://127.0.0.1:8333
|
||||
S3_PORT=8333
|
||||
S3_REGION=us-east-1
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
# The origin a *browser* reaches object storage on, used only when signing temporary read URLs for
|
||||
# direct delivery (ADR 0031). Leave it unset whenever S3_ENDPOINT is itself reachable from a browser,
|
||||
# which is the case for this development stack: Compose publishes the service on loopback, so the
|
||||
# endpoint above serves both roles and direct delivery works with no extra configuration.
|
||||
#
|
||||
# Set it when Core reaches storage by a name a browser cannot resolve — a Compose service name such
|
||||
# as http://object-storage:8333, for instance — and browsers reach it through a public origin:
|
||||
# S3_DELIVERY_ENDPOINT=https://storage.example.org
|
||||
# It must be the origin the browser actually requests. An S3-compatible signature covers the request
|
||||
# Host, so a URL signed for one origin cannot be rewritten to another afterwards; a reverse proxy in
|
||||
# front of storage must therefore pass the original Host header through unchanged. The scheme is not
|
||||
# signed, so terminating TLS at that proxy and forwarding plaintext to the service is fine.
|
||||
# S3_DELIVERY_ENDPOINT=
|
||||
# Namespaces every physical bucket this installation owns, so several Ekklesia-compatible deployments
|
||||
# can share one S3-compatible service. Optional; defaults to "ekklesia".
|
||||
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.
|
||||
|
||||
@@ -6,3 +6,7 @@
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.woff2 binary
|
||||
|
||||
# Reviewed license supplements are named by the SHA-256 of their exact bytes (ADR 0052), so Git must
|
||||
# never rewrite their line endings.
|
||||
packages/module-pack/license-supplements/** -text
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
name: CI
|
||||
|
||||
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
|
||||
#
|
||||
# * `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.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
@@ -20,6 +32,11 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -41,20 +58,54 @@ 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
|
||||
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
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -71,3 +122,214 @@ 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
|
||||
|
||||
package-release:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
revision: ${{ steps.release.outputs.revision }}
|
||||
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ADR 0053's package release gate: build the public package train from this commit, build it
|
||||
# again from a second checkout and require byte-identical artifacts, then install exactly those
|
||||
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
|
||||
# packages, and verifies. Nothing is published and no credential exists here. A future publish
|
||||
# job must depend on this job and publish only the artifacts it proved.
|
||||
- name: Build, reproduce, and consume the public package train
|
||||
run: pnpm test:package-release --keep package-train
|
||||
|
||||
- name: Describe the proven release train
|
||||
id: release
|
||||
run: |
|
||||
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
manifest="$release/ekklesia-package-train.json"
|
||||
{
|
||||
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
cat "$manifest"
|
||||
|
||||
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
|
||||
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
|
||||
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
|
||||
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
|
||||
- name: Hand the release train on
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# The publication tool, given exactly the artifact the package-release job proved, against a
|
||||
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
|
||||
# credential: the disposable registry's accounts are created and discarded inside the job.
|
||||
package-publication:
|
||||
needs: package-release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: package-publication
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Receive the release train the gate proved
|
||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
|
||||
- name: Check it is exactly that train
|
||||
env:
|
||||
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||
run: |
|
||||
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
|
||||
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
|
||||
echo "The received release manifest is not the one the package-release job proved." >&2
|
||||
exit 1
|
||||
fi
|
||||
(cd "$release" && sha256sum --check --strict SHA256SUMS)
|
||||
|
||||
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
|
||||
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
image-build:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
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 one generic platform image (ADR 0044, ADR 0048), proved rather than assumed: it contains no
|
||||
# module, no module template, and no module manifest; its module storage mount point is empty;
|
||||
# EKKLESIA_MODULES_DIR names it; and a checkout with a populated modules/ directory builds an
|
||||
# identical runtime file system. Build only — no registry login, no credentials, no push, no tag
|
||||
# anywhere but this runner's local image store. Tagged per run and attempt, and labeled like
|
||||
# every CI stack, so this job's final step removes the images even if a build is killed.
|
||||
- name: Build the generic platform image and prove it holds no modules
|
||||
run: bash scripts/check-image.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
|
||||
|
||||
deploy-smoke:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Both topologies (ADR 0045), each on the one generic image: same-origin, the default every
|
||||
# installation gets, and split, which installs a test-only module into module storage, removes
|
||||
# it, and restores it (ADR 0048), 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): the generic platform 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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: Publish packages
|
||||
|
||||
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
|
||||
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
|
||||
#
|
||||
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
|
||||
# request or push can start it. It needs the version-set commit of a release (`version` must equal
|
||||
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
|
||||
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
|
||||
# readable (ADR 0054); publishing is what this protects.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: The package train version to publish, equal to the platform version on main.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
|
||||
concurrency:
|
||||
group: publish-packages
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# The only registry-specific setting: where the train is published.
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
|
||||
jobs:
|
||||
package-release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
revision: ${{ steps.release.outputs.revision }}
|
||||
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The same credential-free gate as CI: build, reproduce, and consume the train.
|
||||
- name: Build, reproduce, and consume the public package train
|
||||
run: pnpm test:package-release --keep package-train
|
||||
|
||||
- name: Describe the proven release train
|
||||
id: release
|
||||
run: |
|
||||
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
manifest="$release/ekklesia-package-train.json"
|
||||
{
|
||||
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
|
||||
# name must not start with a dot: the artifact actions leave hidden files out.
|
||||
- name: Hand the release train on
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
publish:
|
||||
needs: package-release
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# Only for the publication tool itself: nothing is built or installed in this job.
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Receive the release train the gate proved
|
||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
|
||||
# The tool validates the whole train against its manifest, that manifest against the gate's,
|
||||
# its version against the request, and its revision against this commit, before it sends
|
||||
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
|
||||
- name: Publish the exact release train
|
||||
env:
|
||||
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
|
||||
TRAIN_VERSION: ${{ inputs.version }}
|
||||
TRAIN_REVISION: ${{ github.sha }}
|
||||
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||
run: |
|
||||
node scripts/publish-package-train.ts \
|
||||
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
|
||||
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
|
||||
--version "$TRAIN_VERSION" \
|
||||
--revision "$TRAIN_REVISION" \
|
||||
--manifest-sha256 "$MANIFEST_SHA256" \
|
||||
--record publication-record.json \
|
||||
--require-public-read
|
||||
|
||||
- name: Keep the publication record
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train-publication-record
|
||||
path: publication-record.json
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
+12
@@ -1,4 +1,8 @@
|
||||
node_modules/
|
||||
# pnpm's content-addressed package store. pnpm keeps it beside node_modules on the same file system,
|
||||
# so when the checkout is its own mount (a CI runner's workspace volume, a bind-mounted container),
|
||||
# `pnpm install` creates it here, at the checkout's root. A package cache, never source.
|
||||
.pnpm-store/
|
||||
.turbo/
|
||||
coverage/
|
||||
dist/
|
||||
@@ -16,3 +20,11 @@ generated/*
|
||||
|
||||
core/.identity-schema-reference.ts
|
||||
core/.identity-schema-check-*
|
||||
|
||||
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
|
||||
packages/module-pack/.fixtures/
|
||||
|
||||
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
|
||||
.package-release/
|
||||
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
|
||||
/package-train/
|
||||
|
||||
@@ -12,12 +12,16 @@ monorepo:
|
||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
||||
feature module, a surface, or an app.
|
||||
- **Modules** (`modules/*`, template at `templates/module`) are backend-only capability packages.
|
||||
They own their own data and migrations, declare capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never contain UI or import Core,
|
||||
another module, or a surface directly.
|
||||
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client
|
||||
contracts only, never Core or module internals directly.
|
||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||
- **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.
|
||||
|
||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||
@@ -74,6 +78,13 @@ ever disagree:
|
||||
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
||||
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
||||
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
||||
- `pnpm test:package-release` — builds the public `@ekklesia/*` package train, proves it
|
||||
reproducible, and proves a standalone module consumes exactly those tarballs. Publishes nothing.
|
||||
Run it for any change to the public packages or their release tooling.
|
||||
- `pnpm test:package-publication` — publishes a release train to a disposable Gitea (Docker) and
|
||||
proves the publication tool. Never the real registry. The real publication is the manual
|
||||
`Publish packages` workflow only; never run `scripts/publish-package-train.ts` against the real
|
||||
registry without the user's explicit intent.
|
||||
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
||||
verify` or the integration suite.
|
||||
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
||||
|
||||
+46
-5
@@ -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
|
||||
@@ -33,10 +34,50 @@ pnpm infrastructure:config
|
||||
- Expose supported entry points through package `exports`; do not import another package's
|
||||
private source paths.
|
||||
- Use `workspace:` ranges for internal package dependencies.
|
||||
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other
|
||||
UI source files.
|
||||
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a
|
||||
surface.
|
||||
- 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`, 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
|
||||
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
|
||||
ordering, startup, or authorization (ADR 0027). A module declares its display name
|
||||
(`manifest.name`); the Dashboard owns where it appears.
|
||||
- A surface is browser code. It may depend on browser-safe shared roles only — never
|
||||
`@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the
|
||||
browser — and it reaches the server over HTTP.
|
||||
- Add or update tests when changing a contract or architectural rule.
|
||||
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
||||
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -21,16 +22,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/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"
|
||||
},
|
||||
|
||||
+346
-9
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
@@ -8,12 +8,40 @@ import type {
|
||||
AuthorizationStore,
|
||||
PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import type {
|
||||
AnyModuleRuntimeServices,
|
||||
DiscoveredModule,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleConfigurationSettings,
|
||||
ModuleConfigurationStore,
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleLifecycleStore,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleRuntimeServicesLookup,
|
||||
ModuleStorageLookup,
|
||||
ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDeviceRoutes } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
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";
|
||||
|
||||
export interface CreateApiAppAuthorizationOptions {
|
||||
readonly evaluator: AuthorizationEvaluator;
|
||||
@@ -32,11 +60,100 @@ 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 same frozen services object the caller's started module runtime already passed to every
|
||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
||||
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
||||
* `ModuleDatabase` for the lifetime of this API runtime. */
|
||||
readonly moduleServices: ModuleRuntimeServices;
|
||||
/** The 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 per-module service lookup the caller's started module runtime used for every
|
||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
|
||||
* reference-identical service object, never a fresh facade or executor built per request. */
|
||||
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||
/** The platform's module UI publication policy — always the one Core builds from the exact
|
||||
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
||||
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
||||
* 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).
|
||||
* Required rather than defaulted, like every other module option here — a caller exercising no
|
||||
* modules says so with an empty snapshot, never falls through to an implicit one.
|
||||
*
|
||||
* The snapshot must come from the same boot as `moduleRoutes` and `moduleUi`; that is what makes
|
||||
* "running" an observation of this process rather than a prediction. See
|
||||
* `./module-lifecycle-routes.js`.
|
||||
*/
|
||||
readonly moduleLifecycle: {
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly store: ModuleLifecycleStore;
|
||||
};
|
||||
/**
|
||||
* The platform's module configuration resource (ADR 0029): the discovered inventory whose
|
||||
* manifests declare what may be configured, the settings this process resolved at boot (used only
|
||||
* to compute `restartRequired`), the deployment's secret source, and the store.
|
||||
*
|
||||
* Required rather than defaulted, like every other module option here. `secrets` is explicit for
|
||||
* the same reason: a caller that supplies no module secrets says so, and a module requiring one is
|
||||
* then reported `secret_missing` rather than silently starting without it.
|
||||
*/
|
||||
readonly moduleConfiguration: {
|
||||
readonly discoveredModules: readonly DiscoveredModule[];
|
||||
readonly bootSettings: ModuleConfigurationSettings;
|
||||
readonly secrets: ModuleSecretSource;
|
||||
readonly store: ModuleConfigurationStore;
|
||||
/** The per-module frozen snapshots the started runtime is actually using — the identical map
|
||||
* `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks
|
||||
* read the same object rather than two independently resolved values. */
|
||||
readonly snapshots: ModuleConfigurationLookup;
|
||||
};
|
||||
/**
|
||||
* The platform's module object storage (ADR 0030): the per-module scoped facades the runtime
|
||||
* started with, and whether a host provider is configured at all.
|
||||
*
|
||||
* Required rather than defaulted, like every other module option here — a caller exercising no
|
||||
* storage says so with an empty map and `providerConfigured: false`, never falls through to an
|
||||
* implicit one. Nothing here carries an endpoint, a region, or a credential: those never leave
|
||||
* Core's provider.
|
||||
*/
|
||||
readonly moduleStorage: {
|
||||
readonly snapshots: ModuleStorageLookup;
|
||||
readonly providerConfigured: boolean;
|
||||
};
|
||||
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
|
||||
* rather than defaulted, like every other module option here: a caller that exercises no
|
||||
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
|
||||
* 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
|
||||
@@ -51,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
|
||||
@@ -117,9 +315,28 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
app.use("/api/auth/*", corsMiddleware);
|
||||
app.use("/api/v1/*", corsMiddleware);
|
||||
|
||||
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
|
||||
// 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 {
|
||||
return await runtime.handleAuthRequest(c.req.raw);
|
||||
const response = await runtime.handleAuthRequest(c.req.raw);
|
||||
// Every Better Auth response — including a session cookie — must never be cached by an
|
||||
// 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.
|
||||
return browserSafeAuthResponse(c.req.raw, response);
|
||||
} catch (error) {
|
||||
return sanitizedErrorResponse(c, error, 500);
|
||||
}
|
||||
@@ -141,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({
|
||||
@@ -152,9 +379,119 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
// A platform-owned route, mounted exactly like Core's own: it serves only artifacts the injected
|
||||
// publication registry already approved, and it can never collide with a module's own routes,
|
||||
// which all live under `/api/v1/modules/`. See `./module-ui-routes.js` for why it is a sibling of
|
||||
// that prefix rather than nested inside it.
|
||||
app.route("/", createModuleUiRoutes({ registry: options.moduleUi }));
|
||||
|
||||
// The browser host's bootstrap query, at the collection root of that same prefix. Unlike the
|
||||
// artifact route above it is authenticated (`core.modules.ui.read`) and per-principal, because it
|
||||
// is the module enumeration ADR 0025 deliberately withheld from the anonymous artifact route.
|
||||
app.route(
|
||||
"/",
|
||||
createModuleUiInventoryRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
moduleUi: options.moduleUi,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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`.
|
||||
app.route(
|
||||
"/",
|
||||
createDashboardRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
moduleUi: options.moduleUi,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
store: options.dashboard,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
||||
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
||||
// rather than inside it. See `./module-lifecycle-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createModuleLifecycleRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
configuration: {
|
||||
secrets: options.moduleConfiguration.secrets,
|
||||
store: options.moduleConfiguration.store,
|
||||
},
|
||||
storage: { providerConfigured: options.moduleStorage.providerConfigured },
|
||||
snapshot: options.moduleLifecycle.snapshot,
|
||||
store: options.moduleLifecycle.store,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
// Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely
|
||||
// to installed modules. See `./module-configuration-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createModuleConfigurationRoutes({
|
||||
bootSettings: options.moduleConfiguration.bootSettings,
|
||||
discoveredModules: options.moduleConfiguration.discoveredModules,
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
secrets: options.moduleConfiguration.secrets,
|
||||
store: options.moduleConfiguration.store,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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
|
||||
* across every route file. Every limit below is deliberately small relative to the actual payload
|
||||
* shapes it guards:
|
||||
*
|
||||
* - `AUTH_REQUEST_MAX_BODY_BYTES` (8 KiB): every enabled Better Auth route
|
||||
* (`/api/auth/sign-up/email`, `/sign-in/email`, `/sign-out`, ...) accepts only an email, a
|
||||
* password (bounded to 128 characters — `core/src/identity/runtime.ts`), and an optional short
|
||||
* display name; the pinned release adds no other enabled provider or field (ADR 0011). A real
|
||||
* payload is well under 200 bytes; 8 KiB leaves generous headroom for a verbose email address or
|
||||
* name while remaining two orders of magnitude below the module ceiling.
|
||||
* - `CORE_MUTATION_MAX_BODY_BYTES` (16 KiB): the one Core JSON mutation body, explicit-grant
|
||||
* creation (`POST /api/v1/grants`), accepts `subjectUserId` (<=128 chars), `permission` (a bounded
|
||||
* identifier), an optional `scope` (<=256 chars), and an optional ISO `expiresAt` string — every
|
||||
* 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 **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_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
|
||||
* size, a declared Content-Length, or any body content. */
|
||||
export function requestBodyTooLargeResponse(c: Context): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,66 @@
|
||||
import { type ClientAddressResolver, TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
|
||||
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||
|
||||
type NodeServerBindings = HttpBindings | Http2Bindings;
|
||||
|
||||
export {
|
||||
type ClientAddressConfig,
|
||||
ClientAddressConfigError,
|
||||
type ClientAddressConfigErrorCode,
|
||||
type ClientAddressConfigInput,
|
||||
type ClientAddressFailureReason,
|
||||
type ClientAddressMode,
|
||||
type ClientAddressResolution,
|
||||
type ClientAddressResolver,
|
||||
createClientAddressConfig,
|
||||
createClientAddressResolver,
|
||||
type DirectClientAddressConfig,
|
||||
findInvalidTrustedProxies,
|
||||
normalizeClientAddress,
|
||||
type TrustedProxyClientAddressConfig,
|
||||
} from "@ekklesia/core/identity";
|
||||
|
||||
/** A fixed, generic diagnostic — never the peer address, forwarded header content, request path, or
|
||||
* any other request detail — logged only when client-address resolution fails closed. */
|
||||
export const CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE =
|
||||
"[client-address] A request's caller address could not be resolved safely; rejecting with 503.";
|
||||
|
||||
function sanitizedClientAddressUnresolvedResponse(): Response {
|
||||
return new Response(JSON.stringify({ code: "CLIENT_ADDRESS_UNRESOLVED" }), {
|
||||
headers: { "cache-control": "no-store", "content-type": "application/json" },
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a `@hono/node-server` fetch callback at the exact Node/API transport boundary — the one
|
||||
* place in this repository below `apps/api` that reads the raw Node `IncomingMessage` — with
|
||||
* Core's framework-agnostic client-address resolution policy (`@ekklesia/core/identity`). Resolves
|
||||
* the caller's trusted address from the real socket peer (`env.incoming.socket.remoteAddress`)
|
||||
* before the wrapped callback — and therefore Better Auth's own rate limiter — ever sees the
|
||||
* request, then overwrites (never merely appends) any externally supplied copy of
|
||||
* `TRUSTED_CLIENT_ADDRESS_HEADER` with that resolved value. A caller can therefore never choose its
|
||||
* own resolved address, whether by spoofing a forwarded header or by sending the internal header
|
||||
* directly. Resolution failure never reaches the wrapped callback at all — no Better Auth logic, no
|
||||
* Core mutation, and no module handler ever runs for a request whose caller address could not be
|
||||
* resolved safely.
|
||||
*/
|
||||
export function wrapFetchWithClientAddress(
|
||||
fetchCallback: (request: Request, env: NodeServerBindings) => Response | Promise<Response>,
|
||||
resolver: ClientAddressResolver,
|
||||
): (request: Request, env: NodeServerBindings) => Promise<Response> {
|
||||
return async (request: Request, env: NodeServerBindings): Promise<Response> => {
|
||||
const resolution = resolver.resolve({
|
||||
headers: request.headers,
|
||||
peerAddress: env.incoming.socket.remoteAddress,
|
||||
});
|
||||
if (!resolution.ok) {
|
||||
console.error(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
|
||||
return sanitizedClientAddressUnresolvedResponse();
|
||||
}
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set(TRUSTED_CLIENT_ADDRESS_HEADER, resolution.address);
|
||||
const sanitizedRequest = new Request(request, { headers });
|
||||
return fetchCallback(sanitizedRequest, env);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
+102
-1
@@ -1,9 +1,30 @@
|
||||
import {
|
||||
assertProductionDatabaseTransport,
|
||||
createDatabaseTransportPolicy,
|
||||
type DatabaseTransportPolicy,
|
||||
type DatabaseTransportPolicyInput,
|
||||
} from "@ekklesia/core/database";
|
||||
import {
|
||||
type CreateIdentityConfigOptions,
|
||||
createIdentityConfig,
|
||||
type IdentityConfig,
|
||||
type IdentityConfigInput,
|
||||
} from "@ekklesia/core/identity";
|
||||
import {
|
||||
createMailProviderConfig,
|
||||
type MailProviderConfig,
|
||||
type MailProviderConfigInput,
|
||||
} from "@ekklesia/core/mail";
|
||||
import {
|
||||
createStorageProviderConfig,
|
||||
type StorageProviderConfig,
|
||||
type StorageProviderConfigInput,
|
||||
} from "@ekklesia/core/storage";
|
||||
import {
|
||||
type ClientAddressConfig,
|
||||
type ClientAddressConfigInput,
|
||||
createClientAddressConfig,
|
||||
} from "./client-address.js";
|
||||
|
||||
export type ApiConfigErrorCode =
|
||||
| "DATABASE_URL_INVALID"
|
||||
@@ -37,17 +58,52 @@ const defaultHost = "127.0.0.1";
|
||||
const defaultPort = 3000;
|
||||
|
||||
export interface ApiRuntimeConfig {
|
||||
readonly clientAddress: ClientAddressConfig;
|
||||
readonly databaseUrl: string;
|
||||
/** The one validated PostgreSQL transport policy this runtime's single pool uses. */
|
||||
readonly databaseTransport: DatabaseTransportPolicy;
|
||||
readonly host: string;
|
||||
readonly identity: IdentityConfig;
|
||||
readonly port: number;
|
||||
/**
|
||||
* The validated host object-storage provider, or `undefined` when this installation has none
|
||||
* (ADR 0030).
|
||||
*
|
||||
* `undefined` is a complete, valid deployment: modules that declare no logical buckets are
|
||||
* unaffected, and a module that does declare one is reported blocked rather than crashing
|
||||
* anything. These values reach Core's provider and nowhere else — no module, module UI, browser
|
||||
* 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 | 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. */
|
||||
readonly databaseTransport?: DatabaseTransportPolicyInput;
|
||||
readonly host?: unknown;
|
||||
readonly identity: IdentityConfigInput;
|
||||
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 {
|
||||
@@ -112,6 +168,51 @@ export function createApiRuntimeConfig(
|
||||
input.identity,
|
||||
options.environment === undefined ? {} : { environment: options.environment },
|
||||
);
|
||||
const clientAddress = createClientAddressConfig(input.clientAddress);
|
||||
const environment = options.environment ?? "production";
|
||||
const databaseTransport = createDatabaseTransportPolicy(input.databaseTransport, {
|
||||
environment,
|
||||
});
|
||||
// Fails here — before `startApi` constructs a pool, Better Auth, the module runtime, or a
|
||||
// listener — if a production deployment would connect without certificate-verified TLS, or if
|
||||
// the URL carries TLS parameters that could override the policy.
|
||||
assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment);
|
||||
|
||||
return Object.freeze({ databaseUrl, host, identity, port });
|
||||
// Validated in the same pass as every other runtime value, before `startApi` constructs a pool,
|
||||
// a provider client, or a listener — so a malformed endpoint or a half-supplied credential pair
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import {
|
||||
DASHBOARD_SORT_ORDER_MAX,
|
||||
DASHBOARD_SORT_ORDER_MIN,
|
||||
type DashboardStore,
|
||||
DashboardStoreError,
|
||||
isValidDashboardIdentifier,
|
||||
normalizeDashboardGroupLabel,
|
||||
resolveDashboardNavigation,
|
||||
} from "@ekklesia/core/dashboard";
|
||||
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js";
|
||||
|
||||
export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||
const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`;
|
||||
const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`;
|
||||
|
||||
/**
|
||||
* One module as the Dashboard presents it: the module's display name, plus the generic mount
|
||||
* descriptor unchanged.
|
||||
*
|
||||
* The descriptor is **embedded rather than flattened**, and that is the layering made visible.
|
||||
* `module` is exactly `HostModuleUiDescriptor` — the host-neutral answer to "what is mountable and
|
||||
* how" (ADR 0026) — and `name` is the Dashboard's presentation concern layered on top. A standalone
|
||||
* host consumes the inner object and never learns that Ekklesia has folders; Ekklesia's shell
|
||||
* consumes the outer one. Neither contract is warped to serve the other, and there is one
|
||||
* construction of the descriptor rather than two that could drift.
|
||||
*/
|
||||
export interface DashboardNavigationModuleView {
|
||||
readonly name: string;
|
||||
readonly module: HostModuleUiDescriptor;
|
||||
}
|
||||
|
||||
export interface DashboardNavigationGroupView {
|
||||
/** The stable key, unchanged by a rename. Addresses the group in the mutation API; never displayed. */
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly order: number;
|
||||
readonly modules: readonly DashboardNavigationModuleView[];
|
||||
}
|
||||
|
||||
export interface DashboardNavigationResponse {
|
||||
readonly groups: readonly DashboardNavigationGroupView[];
|
||||
/** Modules an administrator placed outside every group, and modules never configured at all — a
|
||||
* newly installed module most importantly. Never empty merely because nothing has been arranged. */
|
||||
readonly ungrouped: readonly DashboardNavigationModuleView[];
|
||||
}
|
||||
|
||||
export interface CreateDashboardRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The same publication registry the artifact route and the generic inventory serve from: the one
|
||||
* accepted, mountable module set. Navigation never rescans, never reads a manifest, and never
|
||||
* discovers anything of its own. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
readonly store: DashboardStore;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. A
|
||||
* validation failure the route already rejects can still arrive here from a concurrent change, so
|
||||
* both paths are handled rather than assumed unreachable. */
|
||||
function storeFailure(c: Context, error: unknown): Response {
|
||||
if (error instanceof DashboardStoreError) {
|
||||
if (error.code === "DASHBOARD_GROUP_NOT_FOUND") {
|
||||
return sanitizedFailure(c, 404, error.code);
|
||||
}
|
||||
if (error.code === "DASHBOARD_READ_FAILED" || error.code === "DASHBOARD_WRITE_FAILED") {
|
||||
return sanitizedFailure(c, 503, error.code);
|
||||
}
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
return sanitizedFailure(c, 503, "DASHBOARD_WRITE_FAILED");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parsedSortOrder(value: unknown): number | undefined {
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < DASHBOARD_SORT_ORDER_MIN ||
|
||||
value > DASHBOARD_SORT_ORDER_MAX
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const GROUP_BODY_KEYS = new Set(["label", "order"]);
|
||||
const PLACEMENT_BODY_KEYS = new Set(["groupId", "order"]);
|
||||
|
||||
/**
|
||||
* Structured, closed validation — never a free-form configuration blob. Any key beyond the declared
|
||||
* ones makes the whole body invalid rather than being silently dropped, so a caller cannot smuggle a
|
||||
* field a future version might interpret, and a typo fails loudly instead of quietly doing nothing.
|
||||
*/
|
||||
function parseGroupBody(raw: unknown): { label: string; order: number } | undefined {
|
||||
if (!isRecord(raw) || Object.keys(raw).some((key) => !GROUP_BODY_KEYS.has(key))) {
|
||||
return undefined;
|
||||
}
|
||||
const order = parsedSortOrder(raw.order);
|
||||
const label = normalizeDashboardGroupLabel(raw.label);
|
||||
if (label === undefined || order === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { label, order };
|
||||
}
|
||||
|
||||
function parsePlacementBody(raw: unknown): { groupId: string | null; order: number } | undefined {
|
||||
if (!isRecord(raw) || Object.keys(raw).some((key) => !PLACEMENT_BODY_KEYS.has(key))) {
|
||||
return undefined;
|
||||
}
|
||||
const order = parsedSortOrder(raw.order);
|
||||
const groupId = raw.groupId;
|
||||
if (order === undefined || (groupId !== null && !isValidDashboardIdentifier(groupId))) {
|
||||
return undefined;
|
||||
}
|
||||
return { groupId, order };
|
||||
}
|
||||
|
||||
async function readJsonBody(c: Context): Promise<unknown> {
|
||||
try {
|
||||
return (await c.req.json()) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ekklesia Dashboard's navigation: how this installation presents its mountable modules, and the
|
||||
* narrow, structured API for changing that.
|
||||
*
|
||||
* ## Navigation is layered on the inventory, not merged into it
|
||||
*
|
||||
* `GET /api/v1/module-ui` (ADR 0026) answers *what may be mounted* and stays host-neutral — a
|
||||
* standalone host uses it and never learns Ekklesia has folders. This route answers *how this
|
||||
* Dashboard presents those modules*, and embeds each generic descriptor untouched inside a
|
||||
* presentation wrapper. Keeping them separate is what stops Dashboard semantics leaking into the
|
||||
* portable module-host contract.
|
||||
*
|
||||
* ## Grouping is presentation, and structurally cannot be anything else
|
||||
*
|
||||
* The resolved view is computed by `resolveDashboardNavigation`, a pure function of stored
|
||||
* configuration and the current module set. It reads no manifest, no dependency, no capability, and
|
||||
* no entrypoint, and it produces no relationship between two modules. Modules stay flat (ADR 0021,
|
||||
* ADR 0027): two modules in one group are exactly as related as two in different groups, and moving
|
||||
* one changes a single presentation row — never its artifact, its API root, its permissions, its
|
||||
* dependencies, or when it starts.
|
||||
*
|
||||
* ## Reading versus organizing
|
||||
*
|
||||
* Reading requires `core.modules.ui.read`, the same permission the generic inventory requires,
|
||||
* because this response *contains* that inventory and serves the same audience: any signed-in user
|
||||
* needs it to see anything at all. Organizing requires `core.dashboard.navigation.manage`
|
||||
* (restricted) — a platform permission, never a module's — and, like every other Core mutation, an
|
||||
* exact trusted `Origin` and a bounded body before authorization work begins.
|
||||
*
|
||||
* Holding the manage permission grants no module authority. Every module route remains enforced
|
||||
* against its own declared permission, and no arrangement of folders changes a single authorization
|
||||
* decision.
|
||||
*/
|
||||
export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const middlewareOptions: AuthorizationMiddlewareOptions = {
|
||||
evaluator: options.evaluator,
|
||||
registry: options.registry,
|
||||
runtime: options.runtime,
|
||||
};
|
||||
const requireRead = requirePermission(middlewareOptions, CORE_MODULES_UI_READ);
|
||||
const requireManage = requirePermission(middlewareOptions, CORE_DASHBOARD_NAVIGATION_MANAGE);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
|
||||
routes.get(DASHBOARD_NAVIGATION_PATH, requireRead, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
// Per-principal: the embedded descriptors carry permission hints evaluated for this caller.
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
try {
|
||||
const [configuration, descriptors] = await Promise.all([
|
||||
options.store.readConfiguration(),
|
||||
describeMountableModules(options.evaluator, principal, options.moduleUi),
|
||||
]);
|
||||
const view = resolveDashboardNavigation(
|
||||
configuration,
|
||||
descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })),
|
||||
);
|
||||
const present = (entry: {
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
readonly name: string;
|
||||
}): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name });
|
||||
return c.json(
|
||||
{
|
||||
groups: view.groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.label,
|
||||
modules: group.modules.map(present),
|
||||
order: group.order,
|
||||
})),
|
||||
ungrouped: view.ungrouped.map(present),
|
||||
} satisfies DashboardNavigationResponse,
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DashboardStoreError) {
|
||||
return storeFailure(c, error);
|
||||
}
|
||||
// An authorization-evaluation outage, exactly as the generic inventory reports it: a
|
||||
// permission hint that could not be determined is never rendered as "not granted".
|
||||
return sanitizedFailure(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
routes.put(
|
||||
`${DASHBOARD_GROUPS_PATH}/:groupId`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const groupId = c.req.param("groupId");
|
||||
const body = parseGroupBody(await readJsonBody(c));
|
||||
if (!body || !isValidDashboardIdentifier(groupId)) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
try {
|
||||
// Idempotent by design: creating and renaming are the same operation against a
|
||||
// client-chosen, stable id, so a rename can never orphan the placements pointing at it.
|
||||
const group = await options.store.upsertGroup({
|
||||
id: groupId,
|
||||
label: body.label,
|
||||
sortOrder: body.order,
|
||||
});
|
||||
return c.json({ id: group.id, label: group.label, order: group.sortOrder }, 200);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
routes.delete(`${DASHBOARD_GROUPS_PATH}/:groupId`, requireOrigin, requireManage, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const groupId = c.req.param("groupId");
|
||||
if (!isValidDashboardIdentifier(groupId)) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
try {
|
||||
// Modules in the group become ungrouped through the schema's `on delete set null`, so no
|
||||
// module is lost and no placement is left pointing at a group that no longer exists.
|
||||
await options.store.deleteGroup(groupId);
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error);
|
||||
}
|
||||
});
|
||||
|
||||
routes.put(
|
||||
`${DASHBOARD_PLACEMENTS_PATH}/:moduleId`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const moduleId = c.req.param("moduleId");
|
||||
const body = parsePlacementBody(await readJsonBody(c));
|
||||
if (!body || !isValidDashboardIdentifier(moduleId)) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
try {
|
||||
// A placement may be stored for a module that is not installed: that is how uninstalling
|
||||
// and reinstalling preserves an arrangement. Nothing renders it until the module is back.
|
||||
const placement = await options.store.setPlacement({
|
||||
groupId: body.groupId,
|
||||
moduleId,
|
||||
sortOrder: body.order,
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
groupId: placement.groupId,
|
||||
moduleId: placement.moduleId,
|
||||
order: placement.sortOrder,
|
||||
},
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -1,27 +1,68 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import {
|
||||
createDashboardDrizzleView,
|
||||
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 { createModuleDatabaseView } from "@ekklesia/core/modules";
|
||||
import {
|
||||
createModuleConfigurationView,
|
||||
createModuleDatabaseExecutor,
|
||||
createModuleDatabaseView,
|
||||
createModuleLifecycleView,
|
||||
type ModuleConfigurationDrizzleDatabase,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleDrizzleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly devices: DeviceDrizzleDatabase;
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
readonly module: ModuleDatabase;
|
||||
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
|
||||
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
|
||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/**
|
||||
* 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
|
||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three
|
||||
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()`
|
||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
||||
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`,
|
||||
* `createModuleDatabaseView`). No domain
|
||||
* owns or closes the pool in this composition — `close()`
|
||||
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||
* for callers that only need one domain.
|
||||
@@ -35,7 +76,15 @@ export function createApiDatabase(
|
||||
return Object.freeze({
|
||||
authorization: createAuthorizationDrizzleView(pool),
|
||||
close,
|
||||
devices: createDeviceDrizzleView(pool),
|
||||
dashboard: createDashboardDrizzleView(pool),
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
|
||||
moduleConfiguration: createModuleConfigurationView(pool),
|
||||
moduleLifecycle: createModuleLifecycleView(pool),
|
||||
ping: async (statementTimeoutMs: number) => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
+274
-77
@@ -1,16 +1,17 @@
|
||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||
import type { EkklesiaUserId, GrantId, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
createUserActor,
|
||||
isAuthenticatedPrincipal,
|
||||
safeParseAuthorizationScope,
|
||||
safeParseEkklesiaUserId,
|
||||
safeParseGrantId,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
AUTHORIZATION_GRANT_LIST_MAX_LIMIT,
|
||||
type AuthorizationStore,
|
||||
AuthorizationStoreError,
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
type GrantPageBoundary,
|
||||
type GrantRecord,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { Hono } from "hono";
|
||||
@@ -21,17 +22,41 @@ import {
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
|
||||
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly store: AuthorizationStore;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
/** Injected only so expiration validation is deterministic under test. Production composition
|
||||
* leaves this unset and the routes read real time. */
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
const DEFAULT_LIST_LIMIT = 50;
|
||||
|
||||
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
if (error instanceof AuthorizationStoreError) {
|
||||
const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503;
|
||||
return c.json({ code: error.code }, status);
|
||||
if (error.code === "AUTHORIZATION_GRANT_NOT_FOUND") {
|
||||
return c.json({ code: error.code }, 404);
|
||||
}
|
||||
// The one conflict the caller can act on. It deliberately reveals nothing about the grant that
|
||||
// already exists — not its id, actor, scope, source reference, or expiry — only that an
|
||||
// equivalent authorization is already effective for this subject.
|
||||
if (error.code === "AUTHORIZATION_GRANT_ALREADY_EXISTS") {
|
||||
return c.json({ code: error.code }, 409);
|
||||
}
|
||||
// Defense in depth: the routes below reject both of these before ever reaching the store, so
|
||||
// arriving here means a request-shaped problem slipped through. It is still a client input
|
||||
// failure, never an infrastructure one, and it reports exactly like any other malformed
|
||||
// request rather than leaking the store's internal distinction.
|
||||
if (
|
||||
error.code === "AUTHORIZATION_GRANT_EXPIRATION_INVALID" ||
|
||||
error.code === "AUTHORIZATION_GRANT_LIMIT_INVALID"
|
||||
) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
return c.json({ code: error.code }, 503);
|
||||
}
|
||||
return c.json({ code: "INTERNAL_ERROR" }, 503);
|
||||
}
|
||||
@@ -39,20 +64,55 @@ function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
||||
interface ParsedCreateGrantBody {
|
||||
readonly expiresAt?: Date;
|
||||
readonly permission: PermissionId;
|
||||
readonly scope?: AuthorizationScope;
|
||||
readonly subjectUserId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "scope", "expiresAt"]);
|
||||
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "expiresAt"]);
|
||||
|
||||
/**
|
||||
* Accepts only `subjectUserId`, `permission`, an optional `scope`, and an optional `expiresAt`.
|
||||
* Any other key present — `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`,
|
||||
* `revokedAt`, a grant id, or anything claiming owner/effective-permission state — makes the
|
||||
* whole body invalid rather than being silently dropped, so a caller cannot smuggle
|
||||
* server-derived fields through a request that otherwise looks well-formed.
|
||||
* The one accepted expiration representation: `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to
|
||||
* the parsed `Date`'s own `toISOString()`.
|
||||
*
|
||||
* `new Date(value)` alone was far too permissive. It silently *normalized* impossible dates
|
||||
* (`2026-02-31T00:00:00.000Z` became March 3rd) and accepted locale-shaped input
|
||||
* (`"March 5, 2027"`), date-only values, offsets, and missing milliseconds — so what an operator
|
||||
* typed and what the platform stored could differ without any error. The regular expression pins
|
||||
* the lexical form and the round-trip comparison rejects every value the parser would otherwise
|
||||
* quietly rewrite.
|
||||
*/
|
||||
function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
|
||||
const CANONICAL_INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
||||
|
||||
function parseCanonicalFutureInstant(value: unknown, now: Date): Date | undefined {
|
||||
if (typeof value !== "string" || !CANONICAL_INSTANT_PATTERN.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) {
|
||||
return undefined;
|
||||
}
|
||||
// Strictly future: an expiration equal to or before the acceptance clock produces a grant that
|
||||
// is already dead, which is never what the caller meant.
|
||||
if (parsed.getTime() <= now.getTime()) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts only `subjectUserId`, `permission`, and an optional `expiresAt`. Any other key present —
|
||||
* `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`, `revokedAt`, a grant id, or
|
||||
* anything claiming owner/effective-permission state — makes the whole body invalid rather than
|
||||
* being silently dropped, so a caller cannot smuggle server-derived fields through a request that
|
||||
* otherwise looks well-formed.
|
||||
*
|
||||
* **`scope` is deliberately among the rejected keys**, including when supplied as `null`, empty, or
|
||||
* an otherwise valid scope string. Core's scoped-grant semantics remain intact for trusted internal
|
||||
* callers, but no HTTP route middleware supplies a scope to the evaluator today, so a scope
|
||||
* accepted here would produce a grant that looks meaningful, persists, appears in listings, and can
|
||||
* authorize nothing at all. Accepting it would be offering a switch that is not connected to
|
||||
* anything. When a server-owned resolver exists, this is the one place that changes.
|
||||
*/
|
||||
function parseCreateGrantBody(raw: unknown, now: Date): ParsedCreateGrantBody | undefined {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -70,31 +130,138 @@ function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let scope: AuthorizationScope | undefined;
|
||||
if (body.scope !== undefined) {
|
||||
const parsedScope = safeParseAuthorizationScope(body.scope);
|
||||
if (!parsedScope.success) {
|
||||
return undefined;
|
||||
}
|
||||
scope = parsedScope.data;
|
||||
}
|
||||
|
||||
let expiresAt: Date | undefined;
|
||||
if (body.expiresAt !== undefined) {
|
||||
if (typeof body.expiresAt !== "string") {
|
||||
expiresAt = parseCanonicalFutureInstant(body.expiresAt, now);
|
||||
if (expiresAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedDate = new Date(body.expiresAt);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
expiresAt = parsedDate;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
permission: permission.data,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
subjectUserId: subjectUserId.data,
|
||||
});
|
||||
}
|
||||
|
||||
const CURSOR_VERSION = 1;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
||||
|
||||
/**
|
||||
* An opaque pagination *position*, not an authorization token. It carries only the subject user id,
|
||||
* the boundary row's creation instant, and the boundary grant id — never a permission, scope,
|
||||
* source reference, actor, credential, or secret — so there is nothing in it worth protecting.
|
||||
*
|
||||
* It is deliberately unsigned. Signing would imply the cursor carries authority it does not have:
|
||||
* the permission check and the server-side subject filter remain authoritative on every page, and
|
||||
* a forged cursor can at most name a different position within grants the caller is already
|
||||
* entitled to list. The strict decode below still refuses anything that is not exactly what this
|
||||
* function would emit.
|
||||
*/
|
||||
function encodeCursor(userId: EkklesiaUserId, boundary: GrantPageBoundary): string {
|
||||
return Buffer.from(
|
||||
JSON.stringify({
|
||||
g: boundary.id,
|
||||
t: boundary.createdAt.toISOString(),
|
||||
u: userId,
|
||||
v: CURSOR_VERSION,
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict in every dimension: canonical unpadded base64url, valid UTF-8 JSON, an object with exactly
|
||||
* the four expected keys, the exact version, a subject matching the *requested* one, a canonical
|
||||
* `toISOString()` timestamp, a canonical `GrantId`, and — the catch-all — a re-encoding that
|
||||
* reproduces the supplied string byte-for-byte, which rejects padding, trailing data, and any
|
||||
* alternative encoding of the same payload. A rejected cursor reveals nothing it decoded.
|
||||
*/
|
||||
function decodeCursor(raw: string, userId: EkklesiaUserId): GrantPageBoundary | undefined {
|
||||
if (!BASE64URL_PATTERN.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const payload = parsed as Record<string, unknown>;
|
||||
if (Object.keys(payload).sort().join(",") !== "g,t,u,v") {
|
||||
return undefined;
|
||||
}
|
||||
if (payload.v !== CURSOR_VERSION || payload.u !== userId || typeof payload.t !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const createdAt = new Date(payload.t);
|
||||
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== payload.t) {
|
||||
return undefined;
|
||||
}
|
||||
const id = safeParseGrantId(payload.g);
|
||||
if (!id.success) {
|
||||
return undefined;
|
||||
}
|
||||
const boundary: GrantPageBoundary = Object.freeze({ createdAt, id: id.data });
|
||||
if (encodeCursor(userId, boundary) !== raw) {
|
||||
return undefined;
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
const LIST_ALLOWED_QUERY_KEYS = new Set(["userId", "limit", "cursor"]);
|
||||
/** Canonical base-10, no sign, no leading zero, no decimal point, no whitespace, no exponent. */
|
||||
const CANONICAL_POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
|
||||
|
||||
interface ParsedListQuery {
|
||||
readonly after?: GrantPageBoundary;
|
||||
readonly limit: number;
|
||||
readonly subjectUserId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
/** Fully validated before any store or database work happens, so an invalid page request never
|
||||
* becomes a query, and never becomes a way to probe which subjects or grants exist. */
|
||||
function parseListQuery(url: string): ParsedListQuery | undefined {
|
||||
const searchParams = new URL(url).searchParams;
|
||||
for (const key of searchParams.keys()) {
|
||||
if (!LIST_ALLOWED_QUERY_KEYS.has(key) || searchParams.getAll(key).length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const subjectUserId = safeParseEkklesiaUserId(searchParams.get("userId"));
|
||||
if (!subjectUserId.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let limit = DEFAULT_LIST_LIMIT;
|
||||
const rawLimit = searchParams.get("limit");
|
||||
if (rawLimit !== null) {
|
||||
if (!CANONICAL_POSITIVE_INTEGER_PATTERN.test(rawLimit)) {
|
||||
return undefined;
|
||||
}
|
||||
limit = Number(rawLimit);
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let after: GrantPageBoundary | undefined;
|
||||
const rawCursor = searchParams.get("cursor");
|
||||
if (rawCursor !== null) {
|
||||
after = decodeCursor(rawCursor, subjectUserId.data);
|
||||
if (after === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit,
|
||||
subjectUserId: subjectUserId.data,
|
||||
});
|
||||
}
|
||||
@@ -124,74 +291,53 @@ function serializeGrant(grant: GrantRecord) {
|
||||
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
|
||||
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
|
||||
* those grants remain owned by the module operation that established the relationship.
|
||||
*
|
||||
* Creation is unscoped-only; see `parseCreateGrantBody`.
|
||||
*/
|
||||
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
// Origin is checked first (a rejected cross-origin mutation must never have its body read), then
|
||||
// the body-size limit runs before permission evaluation, so an oversized mutation never reaches
|
||||
// authorization work or the store.
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
|
||||
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId"));
|
||||
if (!subjectUserId.success) {
|
||||
// Reached only after `requireManagePermission` allowed the caller, so query validation can
|
||||
// never disclose a validation distinction — or the existence of any subject or grant — to an
|
||||
// unauthorized caller.
|
||||
const query = parseListQuery(c.req.url);
|
||||
if (!query) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
try {
|
||||
const grants = await options.store.listGrantsForUser(subjectUserId.data, {
|
||||
const page = await options.store.listGrantsForUser(query.subjectUserId, {
|
||||
...(query.after === undefined ? {} : { after: query.after }),
|
||||
limit: query.limit,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
return c.json({ grants: grants.map(serializeGrant) }, 200);
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
});
|
||||
|
||||
routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
const parsed = parseCreateGrantBody(rawBody);
|
||||
if (!parsed) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
|
||||
// A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry
|
||||
// (never the request) decides what may be granted — the same registry `requireManagePermission`
|
||||
// already consults for the caller's own `core.authorization.grants.manage` check — so a caller
|
||||
// cannot pre-create a dormant grant for an invented identifier that later becomes effective the
|
||||
// moment someone registers it. Sanitized identically to any other denial: this must never reveal
|
||||
// whether the identifier was malformed (400, above) or simply unregistered (403, here).
|
||||
if (!options.registry.has(parsed.permission)) {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const grant = await options.store.createGrant({
|
||||
actor: createUserActor(principal.userId),
|
||||
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
|
||||
permission: parsed.permission,
|
||||
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
|
||||
sourceKind: "explicit",
|
||||
userId: parsed.subjectUserId,
|
||||
});
|
||||
return c.json({ grant: serializeGrant(grant) }, 201);
|
||||
return c.json(
|
||||
{
|
||||
grants: page.grants.map(serializeGrant),
|
||||
nextCursor:
|
||||
page.nextBoundary === undefined
|
||||
? null
|
||||
: encodeCursor(query.subjectUserId, page.nextBoundary),
|
||||
},
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
});
|
||||
|
||||
routes.post(
|
||||
"/api/v1/grants/:grantId/revoke",
|
||||
"/api/v1/grants",
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManagePermission,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
@@ -200,6 +346,57 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
const parsed = parseCreateGrantBody(rawBody, now());
|
||||
if (!parsed) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
|
||||
// A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry
|
||||
// (never the request) decides what may be granted — the same registry `requireManagePermission`
|
||||
// already consults for the caller's own `core.authorization.grants.manage` check — so a caller
|
||||
// cannot pre-create a dormant grant for an invented identifier that later becomes effective the
|
||||
// moment someone registers it. Sanitized identically to any other denial: this must never reveal
|
||||
// whether the identifier was malformed (400, above) or simply unregistered (403, here).
|
||||
if (!options.registry.has(parsed.permission)) {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const grant = await options.store.createGrant({
|
||||
actor: createUserActor(principal.userId),
|
||||
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
|
||||
permission: parsed.permission,
|
||||
sourceKind: "explicit",
|
||||
userId: parsed.subjectUserId,
|
||||
});
|
||||
return c.json({ grant: serializeGrant(grant) }, 201);
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
routes.post(
|
||||
"/api/v1/grants/:grantId/revoke",
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManagePermission,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
// Canonical-UUID validation happens after authorization but strictly before any store or
|
||||
// database call: a malformed id previously reached PostgreSQL's own UUID comparison and came
|
||||
// back as an indistinguishable infrastructure failure (503) rather than a request problem.
|
||||
const grantId = safeParseGrantId(c.req.param("grantId"));
|
||||
if (!grantId.success) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
@@ -208,7 +405,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
try {
|
||||
await options.store.revokeGrant({
|
||||
actor: createUserActor(principal.userId),
|
||||
grantId: grantId.data,
|
||||
grantId: grantId.data satisfies GrantId,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
return c.json({ success: true }, 200);
|
||||
|
||||
@@ -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,375 @@
|
||||
import {
|
||||
type ModuleConfigurationDeclaration,
|
||||
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
type ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_CONFIGURATION_MANAGE } from "@ekklesia/core/authorization";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
resolveModuleConfigurationFor,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
|
||||
/**
|
||||
* A **sibling** of `/api/v1/modules/`, never a child — the third application of the rule ADR 0025
|
||||
* established and ADR 0028 reapplied. `config` and `configuration` are both ordinary lowercase
|
||||
* static segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately
|
||||
* register `PUT /config` and land on exactly `/api/v1/modules/announcements/config`. Publishing the
|
||||
* platform's own configuration mutation there would put Core and an installed module in one URL
|
||||
* namespace with registration order deciding which one answers — for a route that changes how
|
||||
* modules behave.
|
||||
*/
|
||||
export const MODULE_CONFIGURATION_PATH = "/api/v1/module-configuration";
|
||||
const MODULE_CONFIGURATION_MODULE_PATH = `${MODULE_CONFIGURATION_PATH}/:moduleId`;
|
||||
|
||||
/**
|
||||
* One declared key as an operator sees it: the module's own declaration, plus what is currently
|
||||
* stored, plus what the running process actually used.
|
||||
*
|
||||
* `value` is **absent for every `secret` key, always**. Not null, not redacted, not a placeholder —
|
||||
* absent, because there is no code path that could put secret material here: Ekklesia never persists
|
||||
* it, and the resolver's snapshot is never serialized. `configured` reports whether the deployment
|
||||
* supplied one, which is the only thing an operator needs to know from here.
|
||||
*/
|
||||
export interface ModuleConfigurationKeyView {
|
||||
readonly key: string;
|
||||
readonly type: ModuleConfigurationDeclaration["type"];
|
||||
readonly required: boolean;
|
||||
readonly description?: string;
|
||||
/** The declared default, for non-secret keys that have one. Part of the module's public manifest. */
|
||||
readonly default?: string | number | boolean;
|
||||
/** Present only for `type: "enum"`. */
|
||||
readonly values?: readonly string[];
|
||||
/** The stored value, when one is stored and the key is not a secret. Absent means "no value
|
||||
* stored" — the default applies, or the key is unset. */
|
||||
readonly value?: string;
|
||||
/** For a secret: whether the deployment supplies it. For anything else: whether a value is stored.
|
||||
* Never implies the value is valid — see the module's `runtime` entry for that. */
|
||||
readonly configured: boolean;
|
||||
}
|
||||
|
||||
export interface ModuleConfigurationView {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly keys: readonly ModuleConfigurationKeyView[];
|
||||
/** Stored keys this module's current manifest no longer declares. Retained rather than deleted, so
|
||||
* a downgrade or a rename does not destroy an operator's value, and surfaced here so it is visible
|
||||
* rather than merely tolerated. */
|
||||
readonly unknownKeys: readonly string[];
|
||||
/** `true` when the stored configuration no longer matches what this process resolved at boot. A
|
||||
* configuration change is applied by the next restart; nothing is hot-reloaded (ADR 0029). */
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface CreateModuleConfigurationRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The discovered inventory this process booted from — the source of every declaration this route
|
||||
* validates against. Never re-discovered: a manifest that changed on disk since boot is not what
|
||||
* the running platform is using. */
|
||||
readonly discoveredModules: readonly DiscoveredModule[];
|
||||
/** The stored settings this process resolved at boot, used only to compute `restartRequired` by
|
||||
* comparison with what is stored now. */
|
||||
readonly bootSettings: ModuleConfigurationSettings;
|
||||
readonly secrets: ModuleSecretSource;
|
||||
readonly store: ModuleConfigurationStore;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. The store's
|
||||
* errors never carry a value, so nothing here can leak one either. */
|
||||
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
|
||||
if (error instanceof ModuleConfigurationStoreError) {
|
||||
if (
|
||||
error.code === "MODULE_CONFIGURATION_READ_FAILED" ||
|
||||
error.code === "MODULE_CONFIGURATION_WRITE_FAILED"
|
||||
) {
|
||||
return sanitizedFailure(c, 503, error.code);
|
||||
}
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
return sanitizedFailure(
|
||||
c,
|
||||
503,
|
||||
fallback === "read" ? "MODULE_CONFIGURATION_READ_FAILED" : "MODULE_CONFIGURATION_WRITE_FAILED",
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const BODY_KEYS = new Set(["values"]);
|
||||
|
||||
/**
|
||||
* The canonical text form of a submitted value, or `undefined` when it is not one this key accepts.
|
||||
*
|
||||
* Accepts JSON's own types — a number for `integer`, a boolean for `boolean`, a string for the rest
|
||||
* — and converts to the single canonical text form the store persists and the resolver parses. It
|
||||
* never accepts a string for a number (`"7"`), so exactly one spelling reaches storage and a stored
|
||||
* row can never validate differently from the request that created it.
|
||||
*/
|
||||
function canonicalStoredValue(
|
||||
declaration: ModuleConfigurationDeclaration,
|
||||
submitted: unknown,
|
||||
): string | undefined {
|
||||
switch (declaration.type) {
|
||||
case "integer": {
|
||||
return typeof submitted === "number" && Number.isSafeInteger(submitted)
|
||||
? String(submitted)
|
||||
: undefined;
|
||||
}
|
||||
case "boolean": {
|
||||
return typeof submitted === "boolean" ? String(submitted) : undefined;
|
||||
}
|
||||
case "string":
|
||||
case "enum":
|
||||
case "url": {
|
||||
return typeof submitted === "string" &&
|
||||
submitted.length <= MODULE_CONFIGURATION_VALUE_MAX_LENGTH
|
||||
? submitted
|
||||
: undefined;
|
||||
}
|
||||
case "secret": {
|
||||
// Unreachable: a secret key is rejected before this is called. The branch exists so adding a
|
||||
// type to the union is a compile error rather than a silent acceptance of secret material.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(c: Context): Promise<unknown> {
|
||||
try {
|
||||
return (await c.req.json()) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function declarationView(
|
||||
declaration: ModuleConfigurationDeclaration,
|
||||
storedValue: string | undefined,
|
||||
secretConfigured: boolean,
|
||||
): ModuleConfigurationKeyView {
|
||||
const isSecret = declaration.type === "secret";
|
||||
return {
|
||||
// A secret is `configured` when the deployment supplies it; anything else when a value is
|
||||
// stored. The two are different questions with one honest answer each.
|
||||
configured: isSecret ? secretConfigured : storedValue !== undefined,
|
||||
key: declaration.key,
|
||||
required: declaration.required,
|
||||
type: declaration.type,
|
||||
...(declaration.description === undefined ? {} : { description: declaration.description }),
|
||||
...(isSecret || declaration.default === undefined ? {} : { default: declaration.default }),
|
||||
...(declaration.type === "enum" ? { values: declaration.values } : {}),
|
||||
// The one place a stored value reaches a response — and structurally never for a secret.
|
||||
...(isSecret || storedValue === undefined ? {} : { value: storedValue }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's module configuration resource: what each installed module declares it needs, what
|
||||
* an operator has stored, and whether the running process is still using it.
|
||||
*
|
||||
* ## Secrets are absent, not filtered
|
||||
*
|
||||
* There is no branch here that redacts a secret, because there is nothing to redact: Ekklesia never
|
||||
* persists secret material, so the store this route reads from contains none. A `secret` key's
|
||||
* `value` field is omitted by construction and `configured` is computed by asking the deployment's
|
||||
* secret source a yes/no question. A future code change that tried to serve a secret value would
|
||||
* have to first invent somewhere to read one from.
|
||||
*
|
||||
* ## Changing configuration does not change the running process
|
||||
*
|
||||
* `PUT` replaces the stored document. It does not restart, reload, or reconfigure anything: modules
|
||||
* receive a frozen snapshot resolved once at startup (ADR 0029), so the response reports
|
||||
* `restartRequired` rather than pretending the change took effect.
|
||||
*
|
||||
* ## Closed validation against the module's own declaration
|
||||
*
|
||||
* A submitted key the module does not declare is rejected, as is a value of the wrong JSON type or a
|
||||
* value failing the module's declared constraints. Nothing is coerced, truncated, or dropped:
|
||||
* `{"slideSeconds": "7"}` is a `400`, not a silent `7`. A secret key is rejected outright — there is
|
||||
* no supported way to submit secret material to this platform.
|
||||
*/
|
||||
export function createModuleConfigurationRoutes(
|
||||
options: CreateModuleConfigurationRoutesOptions,
|
||||
): Hono {
|
||||
const routes = new Hono();
|
||||
const requireManage = requirePermission(
|
||||
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
const modulesById = new Map(
|
||||
options.discoveredModules.map((module) => [module.manifest.id, module] as const),
|
||||
);
|
||||
|
||||
const storedFor = (
|
||||
settings: ModuleConfigurationSettings,
|
||||
moduleId: string,
|
||||
): ReadonlyMap<string, string> =>
|
||||
new Map(
|
||||
settings.records
|
||||
.filter((record) => record.moduleId === moduleId)
|
||||
.map((record) => [record.key, record.value] as const),
|
||||
);
|
||||
|
||||
/** Two documents differ when the exact set of (key, value) pairs differs. Compared against the
|
||||
* settings this process actually resolved at boot, so `restartRequired` is an observation rather
|
||||
* than a guess. */
|
||||
const differsFromBoot = (moduleId: string, current: ReadonlyMap<string, string>): boolean => {
|
||||
const boot = storedFor(options.bootSettings, moduleId);
|
||||
if (boot.size !== current.size) {
|
||||
return true;
|
||||
}
|
||||
for (const [key, value] of current) {
|
||||
if (boot.get(key) !== value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildView = (
|
||||
module: DiscoveredModule,
|
||||
settings: ModuleConfigurationSettings,
|
||||
): ModuleConfigurationView => {
|
||||
const stored = storedFor(settings, module.manifest.id);
|
||||
const resolved = resolveModuleConfigurationFor(module.manifest, {
|
||||
secrets: options.secrets,
|
||||
settings,
|
||||
});
|
||||
return {
|
||||
keys: module.manifest.configuration.map((declaration) =>
|
||||
declarationView(
|
||||
declaration as ModuleConfigurationDeclaration,
|
||||
stored.get(declaration.key),
|
||||
// Asked, never read into the response: the answer is a boolean, and the value it was
|
||||
// derived from never leaves the resolver.
|
||||
declaration.type === "secret"
|
||||
? options.secrets.get(module.manifest.id, declaration.key) !== undefined
|
||||
: false,
|
||||
),
|
||||
),
|
||||
moduleId: module.manifest.id,
|
||||
restartRequired: differsFromBoot(module.manifest.id, stored),
|
||||
unknownKeys: resolved.unknownKeys,
|
||||
version: module.manifest.version,
|
||||
};
|
||||
};
|
||||
|
||||
routes.get(MODULE_CONFIGURATION_MODULE_PATH, requireManage, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const moduleId = c.req.param("moduleId");
|
||||
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
|
||||
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
|
||||
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||
}
|
||||
try {
|
||||
return c.json(buildView(module, await options.store.readSettings()), 200);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "read");
|
||||
}
|
||||
});
|
||||
|
||||
routes.put(
|
||||
MODULE_CONFIGURATION_MODULE_PATH,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const moduleId = c.req.param("moduleId");
|
||||
const raw = await readJsonBody(c);
|
||||
if (!isRecord(raw) || Object.keys(raw).some((key) => !BODY_KEYS.has(key))) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const submitted = raw.values;
|
||||
if (!isRecord(submitted)) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
|
||||
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
|
||||
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||
}
|
||||
|
||||
const declarations = new Map(
|
||||
module.manifest.configuration.map(
|
||||
(declaration) =>
|
||||
[declaration.key, declaration as ModuleConfigurationDeclaration] as const,
|
||||
),
|
||||
);
|
||||
const values: { key: string; value: string }[] = [];
|
||||
// `Object.keys` on a parsed JSON object never yields inherited properties, and every key is
|
||||
// re-validated against the platform's one configuration-key grammar before it is used, so a
|
||||
// body carrying `__proto__` or `constructor` is a plain `400` rather than anything stranger.
|
||||
for (const key of Object.keys(submitted)) {
|
||||
if (!moduleConfigurationKeySchema.safeParse(key).success) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const declaration = declarations.get(key);
|
||||
if (!declaration) {
|
||||
return sanitizedFailure(c, 400, "CONFIGURATION_KEY_UNDECLARED");
|
||||
}
|
||||
if (declaration.type === "secret") {
|
||||
// Deliberate and permanent for this pass: Ekklesia has no encryption at rest, so there is
|
||||
// no honest way to accept secret material over HTTP and store it. Secrets are supplied by
|
||||
// the deployment environment (ADR 0029).
|
||||
return sanitizedFailure(c, 400, "CONFIGURATION_SECRET_NOT_WRITABLE");
|
||||
}
|
||||
const canonical = canonicalStoredValue(declaration, submitted[key]);
|
||||
if (canonical === undefined) {
|
||||
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
|
||||
}
|
||||
values.push({ key, value: canonical });
|
||||
}
|
||||
|
||||
// Constraint validation runs through the **same resolver the platform boots with**, over the
|
||||
// exact document about to be stored, rather than a second copy of the type rules here. That
|
||||
// is what guarantees the API and startup can never disagree about whether a value is valid:
|
||||
// there is one implementation, and this is it.
|
||||
//
|
||||
// An *incomplete* document is accepted deliberately — an operator may save partial work, and
|
||||
// the module is then reported `blocked · configuration_missing` until they finish. An
|
||||
// *invalid* one never is: storing a value that could not survive the next boot would hand
|
||||
// back a success for a change that silently breaks the module.
|
||||
const candidate = resolveModuleConfigurationFor(module.manifest, {
|
||||
secrets: options.secrets,
|
||||
settings: {
|
||||
records: values.map((entry) => ({ ...entry, moduleId: module.manifest.id })),
|
||||
},
|
||||
});
|
||||
if (candidate.issue?.reason === "configuration_invalid") {
|
||||
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
|
||||
}
|
||||
|
||||
try {
|
||||
await options.store.replace({ moduleId: module.manifest.id, values });
|
||||
// Re-read rather than predict, so this response and a later GET can never disagree.
|
||||
return c.json(buildView(module, await options.store.readSettings()), 200);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "write");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import type {
|
||||
ModuleId,
|
||||
ModuleRouteHandlerContext,
|
||||
ModuleRuntimeServices,
|
||||
RegisteredModuleHttpRoute,
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
EMPTY_MODULE_STORAGE,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleId,
|
||||
type ModuleRouteHandlerContext,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
AnyModuleRuntimeServices,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleDatabase,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleRuntimeServicesLookup,
|
||||
ModuleStorageLookup,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
@@ -11,6 +21,7 @@ import {
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { platformBodyLimit } from "./body-limits.js";
|
||||
|
||||
const MODULE_ROUTE_PREFIX = "/api/v1/modules";
|
||||
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]);
|
||||
@@ -29,7 +40,28 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
||||
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||
* after authorization succeeds. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||
/**
|
||||
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
||||
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
||||
* rather than two independently resolved values. A module with no entry receives
|
||||
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
|
||||
*
|
||||
* Like `services`, this is keyed by module id: a handler can only
|
||||
* ever reach its own module's configuration, because that is the only entry looked up for it.
|
||||
*/
|
||||
readonly configurations: ModuleConfigurationLookup;
|
||||
/**
|
||||
* Per-module scoped object storage — the exact map the module runtime started from (ADR 0030), so
|
||||
* a handler and that module's lifecycle hooks share one facade rather than two independently
|
||||
* resolved ones.
|
||||
*
|
||||
* Keyed by module id, like `configurations` and unlike `services`: a handler can only ever reach
|
||||
* 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
|
||||
@@ -44,13 +76,30 @@ export class ModuleRouteConfigurationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The root a module's own HTTP API is mounted at — the one place that URL shape is constructed.
|
||||
*
|
||||
* Exported because a browser host has to resolve `context.api.request("records")`
|
||||
* (`ModuleUiApiClient`, `@ekklesia/module-ui-sdk`) to a real Ekklesia URL, and a module UI must
|
||||
* never build that prefix itself: it is Ekklesia's private deployment detail, and a module that
|
||||
* hardcoded it could not be hosted anywhere else (ADR 0022). The server hands the resolved base to
|
||||
* the host through the module UI inventory (`./module-ui-inventory.js`) rather than letting the
|
||||
* browser re-spell this template, so this prefix can change without touching either.
|
||||
*
|
||||
* Percent-encoded for the same reason `moduleUiArtifactUrl` is: 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 real module id (lowercase kebab-case, `moduleIdSchema`) is unaffected by the encoding.
|
||||
*/
|
||||
export function moduleApiBaseUrl(moduleId: string): string {
|
||||
return `${MODULE_ROUTE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
/** "/" is the module's own root and maps to the bare module prefix, never a required trailing
|
||||
* slash; every other local path appends directly. Root and non-root therefore never collide, and
|
||||
* `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */
|
||||
function moduleRoutePath(moduleId: ModuleId, routePath: string): string {
|
||||
return routePath === "/"
|
||||
? `${MODULE_ROUTE_PREFIX}/${moduleId}`
|
||||
: `${MODULE_ROUTE_PREFIX}/${moduleId}${routePath}`;
|
||||
const base = moduleApiBaseUrl(moduleId);
|
||||
return routePath === "/" ? base : `${base}${routePath}`;
|
||||
}
|
||||
|
||||
function sanitizedModuleHandlerFailure(c: Context): Response {
|
||||
@@ -155,9 +204,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).
|
||||
*
|
||||
@@ -185,20 +235,40 @@ export function mountModuleHttpRoutes(
|
||||
if (!options.registry.has(route.permission)) {
|
||||
throw new ModuleRouteConfigurationError();
|
||||
}
|
||||
const services =
|
||||
"get" in options.services ? options.services.get(route.moduleId) : options.services;
|
||||
if (!services) {
|
||||
throw new ModuleRouteConfigurationError();
|
||||
}
|
||||
|
||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||
// 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({
|
||||
const context = Object.freeze({
|
||||
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
||||
moduleId: route.moduleId,
|
||||
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
services: options.services,
|
||||
});
|
||||
realtime: options.realtime.publisherFor(route.moduleId),
|
||||
services,
|
||||
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await route.handler(c.req.raw, context);
|
||||
response = await (
|
||||
route.handler as (
|
||||
request: Request,
|
||||
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
) => PromiseLike<Response> | Response
|
||||
)(c.req.raw, context);
|
||||
} catch {
|
||||
return sanitizedModuleHandlerFailure(c);
|
||||
}
|
||||
@@ -212,9 +282,16 @@ export function mountModuleHttpRoutes(
|
||||
// array — each branch below is therefore a distinct, fixed-arity call rather than one call
|
||||
// spreading a variable-length handlers array.
|
||||
if (STATE_CHANGING_METHODS.has(route.method)) {
|
||||
app.on(route.method, path, requireOrigin, requireRoutePermission, finalHandler);
|
||||
app.on(
|
||||
route.method,
|
||||
path,
|
||||
requireOrigin,
|
||||
routeBodyLimit,
|
||||
requireRoutePermission,
|
||||
finalHandler,
|
||||
);
|
||||
} else {
|
||||
app.on(route.method, path, requireRoutePermission, finalHandler);
|
||||
app.on(route.method, path, routeBodyLimit, requireRoutePermission, finalHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import type {
|
||||
ModuleBlockedReason,
|
||||
ModuleBlockingDependency,
|
||||
ModuleRuntimeState,
|
||||
ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
|
||||
import {
|
||||
type ModuleConfigurationStore,
|
||||
type ModuleLifecycleSnapshot,
|
||||
type ModuleStorageAvailability,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
type ModuleLifecycleView,
|
||||
resolveModuleLifecycleView,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
|
||||
/**
|
||||
* A **sibling** of `/api/v1/modules/`, never a child of it — the same rule ADR 0025 established for
|
||||
* `/api/v1/module-ui`, and for the same concrete reason. Every module HTTP route is mounted under
|
||||
* `/api/v1/modules/<module-id>/<local path>`, and `moduleRoutePathSchema` accepts `enabled` as an
|
||||
* ordinary static segment, so a module named `announcements` registering `PUT /enabled` would land
|
||||
* on exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle
|
||||
* mutation there would put Core and an installed module in one URL namespace with registration
|
||||
* order deciding which one answers — for a route that changes whether modules run. A sibling prefix
|
||||
* cannot collide.
|
||||
*/
|
||||
export const MODULE_LIFECYCLE_PATH = "/api/v1/module-lifecycle";
|
||||
const MODULE_LIFECYCLE_ENABLED_PATH = `${MODULE_LIFECYCLE_PATH}/:moduleId/enabled`;
|
||||
|
||||
/**
|
||||
* One module as an operator sees it. Two clearly separated axes, deliberately never merged into a
|
||||
* single status word.
|
||||
*
|
||||
* `configured` is the decision that survives restarts. `runtime` is what *this process* did. They
|
||||
* can legitimately disagree — Ekklesia does not hot-load or hot-unload modules — and
|
||||
* `restartRequired` names that disagreement instead of hiding it behind a status that would be a
|
||||
* lie in one direction or the other.
|
||||
*
|
||||
* Nothing here carries a filesystem path, a package name, a manifest path, a directory, an
|
||||
* entrypoint, a caught error, or a stack. The only identifiers are module ids, capability ids, and
|
||||
* a fixed reason code from a closed set — all of them values the platform itself produced and
|
||||
* validated.
|
||||
*/
|
||||
export interface ModuleLifecycleModuleView {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly configured: {
|
||||
readonly enabled: boolean;
|
||||
/** `false` means nobody has decided and the platform default (enabled) applies. */
|
||||
readonly explicit: boolean;
|
||||
};
|
||||
readonly runtime: {
|
||||
readonly state: ModuleRuntimeState;
|
||||
readonly reason?: ModuleBlockedReason;
|
||||
/** Present for a dependency reason. */
|
||||
readonly dependency?: ModuleBlockingDependency;
|
||||
/** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is
|
||||
* declared in a public manifest; a value may be a credential or arbitrary operator text. */
|
||||
readonly configurationKey?: string;
|
||||
/** Present for a storage reason (ADR 0030): the logical bucket the module declared, never an
|
||||
* endpoint, a credential, or the physical bucket the host maps it to. */
|
||||
readonly storageBucketId?: string;
|
||||
};
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface ModuleLifecycleResponse {
|
||||
readonly modules: readonly ModuleLifecycleModuleView[];
|
||||
}
|
||||
|
||||
export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The boot snapshot: the discovered inventory this process started from, the eligibility it
|
||||
* resolved, and the modules the runtime reports it actually started. Captured once at startup and
|
||||
* never recomputed — this endpoint describes the platform that is running, not one that would run
|
||||
* if it were restarted right now. */
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly store: ModuleLifecycleStore;
|
||||
/**
|
||||
* How to re-read the *other* axis that decides eligibility (ADR 0029). Since a module's own
|
||||
* configuration can block it, `restartRequired` must be computed against live configuration as
|
||||
* well as live enablement — otherwise correcting a missing required value would report no pending
|
||||
* restart for a module the next boot really will start.
|
||||
*/
|
||||
readonly configuration: {
|
||||
readonly store: ModuleConfigurationStore;
|
||||
readonly secrets: ModuleSecretSource;
|
||||
};
|
||||
/** The other axis that decides eligibility (ADR 0030). A boolean, never a probe: whether storage
|
||||
* is *configured* decides eligibility, while whether it is *reachable right now* never does. */
|
||||
readonly storage: ModuleStorageAvailability;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary, exactly as
|
||||
* `dashboard-routes.ts` does. */
|
||||
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
|
||||
if (error instanceof ModuleLifecycleStoreError) {
|
||||
if (
|
||||
error.code === "MODULE_LIFECYCLE_READ_FAILED" ||
|
||||
error.code === "MODULE_LIFECYCLE_WRITE_FAILED"
|
||||
) {
|
||||
return sanitizedFailure(c, 503, error.code);
|
||||
}
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
return sanitizedFailure(
|
||||
c,
|
||||
503,
|
||||
fallback === "read" ? "MODULE_LIFECYCLE_READ_FAILED" : "MODULE_LIFECYCLE_WRITE_FAILED",
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const ENABLED_BODY_KEYS = new Set(["enabled"]);
|
||||
|
||||
/**
|
||||
* Structured, closed validation — never a configuration blob. Any key beyond `enabled` makes the
|
||||
* whole body invalid rather than being silently dropped, so a caller cannot smuggle a field a
|
||||
* future version might interpret, and a typo fails loudly instead of quietly doing nothing. Mirrors
|
||||
* `parseGroupBody`/`parsePlacementBody` (`./dashboard-routes.js`) exactly.
|
||||
*/
|
||||
function parseEnabledBody(raw: unknown): { enabled: boolean } | undefined {
|
||||
if (!isRecord(raw) || Object.keys(raw).some((key) => !ENABLED_BODY_KEYS.has(key))) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : undefined;
|
||||
}
|
||||
|
||||
async function readJsonBody(c: Context): Promise<unknown> {
|
||||
try {
|
||||
return (await c.req.json()) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
|
||||
return {
|
||||
configured: { enabled: view.configured.enabled, explicit: view.configured.explicit },
|
||||
id: view.moduleId,
|
||||
restartRequired: view.restartRequired,
|
||||
runtime: {
|
||||
state: view.runtime.state,
|
||||
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
|
||||
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
|
||||
...(view.runtime.configurationKey === undefined
|
||||
? {}
|
||||
: { configurationKey: view.runtime.configurationKey }),
|
||||
...(view.runtime.storageBucketId === undefined
|
||||
? {}
|
||||
: { storageBucketId: view.runtime.storageBucketId }),
|
||||
},
|
||||
version: view.version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's operational lifecycle resource: which installed modules are enabled, what this
|
||||
* process actually did with each of them, and the narrow, structured API for changing that.
|
||||
*
|
||||
* ## Enablement is host configuration, and structurally cannot be anything else
|
||||
*
|
||||
* A module neither declares nor reads its own enabled state. Nothing in a manifest, a capability, a
|
||||
* permission declaration, or a lifecycle hook can influence a value stored here, and a module is
|
||||
* never told it is enabled — its host simply loads and starts it, or does not. That is what keeps
|
||||
* the same module implementation reusable under a future standalone host, whose loading decisions
|
||||
* are its own (ADR 0028).
|
||||
*
|
||||
* ## Changing configuration does not change the running process
|
||||
*
|
||||
* `PUT` writes a decision. It does not start, stop, load, unload, or reconfigure anything: there is
|
||||
* no hot module installation or removal in this platform (ADR 0015, unchanged). The response
|
||||
* therefore reports the new configuration *and* the unchanged runtime observation *and*
|
||||
* `restartRequired`, rather than pretending the module stopped. Hot-unloading a module to make a
|
||||
* button feel immediate would trade a real guarantee — that a module's ESM evaluation happens
|
||||
* exactly once, and can never be rolled back — for a cosmetic one.
|
||||
*
|
||||
* ## Only a module this process can see may be configured
|
||||
*
|
||||
* `PUT` against an id that is not in the boot inventory is a `404`. Unlike an inert Dashboard
|
||||
* placement, an enablement record is operational configuration, and accepting one for an arbitrary
|
||||
* identifier would make this an unbounded write surface for a delegable permission. *Existing*
|
||||
* records for modules that are no longer deployed are still retained and simply ignored, so
|
||||
* redeploying a module restores the decision (see `resolveModuleEligibility`).
|
||||
*
|
||||
* ## Reading is administrative too
|
||||
*
|
||||
* Both verbs require `core.modules.lifecycle.manage` (restricted). This resource is the only place
|
||||
* a disabled module, a blocked module, and the required-dependency edges between them are visible;
|
||||
* `core.modules.ui.read` deliberately covers only what is mountable. Mutations additionally require
|
||||
* an exact trusted `Origin` and a bounded body before authorization work begins, exactly like every
|
||||
* other Core mutation.
|
||||
*/
|
||||
export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const requireManage = requirePermission(
|
||||
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
const installedModuleIds = new Set(
|
||||
options.snapshot.discoveredModules.map((module) => module.manifest.id),
|
||||
);
|
||||
|
||||
routes.get(MODULE_LIFECYCLE_PATH, requireManage, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
// Not per-principal in content, but it is cookie-authorized, so it varies for the same reason
|
||||
// every other authorized Core read does.
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
try {
|
||||
const [configuration, settings] = await Promise.all([
|
||||
options.store.readConfiguration(),
|
||||
options.configuration.store.readSettings(),
|
||||
]);
|
||||
const modules = resolveModuleLifecycleView(
|
||||
options.snapshot,
|
||||
configuration,
|
||||
{ secrets: options.configuration.secrets, settings },
|
||||
options.storage,
|
||||
).map(present);
|
||||
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "read");
|
||||
}
|
||||
});
|
||||
|
||||
routes.put(
|
||||
MODULE_LIFECYCLE_ENABLED_PATH,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const moduleId = c.req.param("moduleId");
|
||||
const body = parseEnabledBody(await readJsonBody(c));
|
||||
if (!body) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
if (!installedModuleIds.has(moduleId)) {
|
||||
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||
}
|
||||
try {
|
||||
await options.store.setEnabled({ enabled: body.enabled, moduleId });
|
||||
// Re-read rather than predict: the response describes the configuration that is actually
|
||||
// stored now, resolved against the same boot snapshot the collection read uses, so the two
|
||||
// endpoints can never disagree about `restartRequired`.
|
||||
const [configuration, settings] = await Promise.all([
|
||||
options.store.readConfiguration(),
|
||||
options.configuration.store.readSettings(),
|
||||
]);
|
||||
const view = resolveModuleLifecycleView(
|
||||
options.snapshot,
|
||||
configuration,
|
||||
{ secrets: options.configuration.secrets, settings },
|
||||
options.storage,
|
||||
).find((entry) => entry.moduleId === moduleId);
|
||||
if (!view) {
|
||||
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
|
||||
}
|
||||
return c.json(present(view), 200);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "write");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||
import type {
|
||||
ModuleUiPublicationRegistry,
|
||||
PublishableModuleContribution,
|
||||
PublishableModuleUi,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||
import {
|
||||
MODULE_UI_ASSET_PREFIX,
|
||||
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
|
||||
* mount, and `/api/v1/module-ui/<module-id>/<version>` (ADR 0025) serves one artifact's bytes. One
|
||||
* prefix, one resource, two representations.
|
||||
*/
|
||||
export const MODULE_UI_INVENTORY_PATH = MODULE_UI_ASSET_PREFIX;
|
||||
|
||||
/**
|
||||
* Exactly what a browser host needs to mount one module's UI, and deliberately nothing else.
|
||||
*
|
||||
* No filesystem path, package root, package name, server/schema/migrations entrypoint, manifest
|
||||
* version, capability, dependency, or storage bucket appears here. Those are server-side facts a
|
||||
* browser has no use for, and several of them are precisely what ADR 0025's publication route
|
||||
* exists to keep off the wire.
|
||||
*
|
||||
* There is also no `name`, `icon`, `description`, `category`, `folder`, or `order`. Those are
|
||||
* *navigation* concerns, and ADR 0021 established that Dashboard grouping is presentation
|
||||
* configuration an administrator owns rather than something a module declares. A field added here
|
||||
* now would quietly become module-owned navigation metadata before that design exists; the
|
||||
* temporary selector renders the module id instead.
|
||||
*/
|
||||
export interface HostModuleUiDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
/** Where the browser fetches this module's compiled UI artifact. Produced by the server's own
|
||||
* 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.
|
||||
*
|
||||
* Scoped to the module deliberately: a host has no business handing one module the platform's
|
||||
* whole authorization state, and the module's manifest already declares exactly which permissions
|
||||
* its UI can meaningfully ask about. Granted ids only — a permission the principal lacks is simply
|
||||
* absent, so this never enumerates a module's full permission surface to someone who holds none of
|
||||
* it.
|
||||
*
|
||||
* **These are presentation hints, not authority** (ADR 0022). They exist so a UI can hide a
|
||||
* control instead of offering one that would fail. Every module route remains enforced by
|
||||
* `requirePermission` against the composed platform registry, and that enforcement neither reads
|
||||
* 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
|
||||
* no discovery, reads no manifest, and touches no filesystem; a module absent from this registry
|
||||
* is absent from the inventory, by construction rather than by filtering. Named `moduleUi` rather
|
||||
* than `registry` because `AuthorizationMiddlewareOptions.registry` is already the *permission*
|
||||
* registry, and one route needs both. */
|
||||
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates one module's declared permissions against the caller, returning only the granted ids.
|
||||
*
|
||||
* Uses Core's one evaluator, so baselines, the platform-owner bypass, grant expiry, and
|
||||
* deny-by-default all behave exactly as they do on a real request — there is no second,
|
||||
* browser-only interpretation of what a permission means. An infrastructure failure propagates as
|
||||
* `AuthorizationEvaluationError` rather than being folded into "not granted", because a hint that
|
||||
* silently read "denied" during a database outage would blank the whole UI and look like a
|
||||
* permissions problem.
|
||||
*/
|
||||
/** 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[],
|
||||
): Promise<readonly string[]> {
|
||||
const decisions = await Promise.all(
|
||||
declarations.map(async (declaration) => ({
|
||||
allowed: (
|
||||
await evaluator.authorize({
|
||||
baseline: declaration.baseline,
|
||||
permission: declaration.id,
|
||||
principal,
|
||||
})
|
||||
).allowed,
|
||||
id: String(declaration.id),
|
||||
})),
|
||||
);
|
||||
return decisions.filter((decision) => decision.allowed).map((decision) => decision.id);
|
||||
}
|
||||
|
||||
async function describeModule(
|
||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||
principal: EkklesiaPrincipal,
|
||||
published: PublishableModuleUi,
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
): Promise<HostModuleUiDescriptor> {
|
||||
const display = registry.resolveDisplay(published.moduleId, published.version);
|
||||
return {
|
||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||
contributions: describeModuleContributions(registry, published.moduleId),
|
||||
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
|
||||
id: published.moduleId,
|
||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||
ui: { url: moduleUiArtifactUrl(published) },
|
||||
version: published.version,
|
||||
};
|
||||
}
|
||||
|
||||
/** One mountable module, described once: the host-neutral mount descriptor plus the module's own
|
||||
* display name, kept beside it rather than inside it. */
|
||||
export interface MountableModuleDescription {
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
/** `manifest.name`. Presentation *identity* the module owns — never its placement, which the
|
||||
* Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the
|
||||
* host-neutral contract a standalone host consumes stays free of presentation concerns. */
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one composition of the mountable-module set, shared by the generic inventory route and the
|
||||
* Dashboard navigation route (`./dashboard-routes.js`).
|
||||
*
|
||||
* Both derive from the same publication registry and the same evaluator, so the two endpoints can
|
||||
* never disagree about what exists, what its URLs are, or what the caller may do inside it. Sharing
|
||||
* this function rather than duplicating the mapping is what makes that a structural property instead
|
||||
* of a convention.
|
||||
*/
|
||||
export async function describeMountableModules(
|
||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||
principal: EkklesiaPrincipal,
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
): Promise<readonly MountableModuleDescription[]> {
|
||||
return Promise.all(
|
||||
registry.list().map(async (published) => ({
|
||||
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||
name: published.name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that
|
||||
* could not be determined is never reported as "not granted". Carries no module id, permission id,
|
||||
* or caught error. */
|
||||
function sanitizedInventoryFailure(c: Context): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser host's one bootstrap query: which module UIs may I mount, where do I fetch each one,
|
||||
* where does each one's API live, and what may this viewer do inside it.
|
||||
*
|
||||
* ## It is not a module management API
|
||||
*
|
||||
* It lists mountable UIs and nothing else. There is no create, update, enable, disable, install, or
|
||||
* configure; no module that declares no UI; and no field beyond what constructing a
|
||||
* `ModuleUiHostContext` requires. Ekklesia has no enable/disable state to report —
|
||||
* `ModuleLifecycleStage` is vocabulary with no runtime state machine behind it — and inventing one
|
||||
* here would be inventing a lifecycle rather than reporting one.
|
||||
*
|
||||
* ## The module set comes from the publication registry, never a rescan
|
||||
*
|
||||
* `moduleUi.list()` is the exact accepted set ADR 0025 publishes artifacts from: modules that
|
||||
* survived discovery and declare `entrypoints.ui`, captured after the module runtime started. This
|
||||
* route never scans a directory, reads a manifest, or reconstructs a module from `package.json`, so
|
||||
* the inventory and the artifact route can never disagree about what exists. Order is the
|
||||
* registry's own discovery order, which is deterministic (topologically sorted, then by id).
|
||||
*
|
||||
* ## Authenticated, deliberately, unlike the artifact route
|
||||
*
|
||||
* ADR 0025 made the artifact bytes anonymous — fetching compiled UI code confers no authority — but
|
||||
* was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong
|
||||
* version collapse into one indistinguishable `404`, so a caller must already know both halves of an
|
||||
* identity to learn anything. **This endpoint is that enumeration.** Serving it anonymously would
|
||||
* hand over exactly what that route withholds, so it requires `core.modules.ui.read` (baseline
|
||||
* `authenticated`): any signed-in user of the installation, and nobody else.
|
||||
*
|
||||
* Enforcement goes through the same `requirePermission` middleware every other protected route uses
|
||||
* — no second browser-auth mechanism — which also resolves the principal exactly once and keeps a
|
||||
* genuine denial (`403`) distinct from an infrastructure failure (`503`).
|
||||
*
|
||||
* The response is per-principal (its `permissions` depend on who is asking), so it is `no-store` and
|
||||
* varies on `Origin` and `Cookie`, exactly like `/api/v1/principal`.
|
||||
*/
|
||||
export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const requireInventoryPermission = requirePermission(
|
||||
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||
CORE_MODULES_UI_READ,
|
||||
);
|
||||
|
||||
routes.get(MODULE_UI_INVENTORY_PATH, requireInventoryPermission, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
// Resolved and already authorized by the middleware above; never resolved a second time here.
|
||||
// The `authenticated` baseline guarantees this is an authenticated principal.
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
try {
|
||||
const described = await describeMountableModules(
|
||||
options.evaluator,
|
||||
principal,
|
||||
options.moduleUi,
|
||||
);
|
||||
// Only the host-neutral descriptor reaches this response: the display name stays out of the
|
||||
// portable contract and is carried by the Dashboard navigation view instead.
|
||||
const modules = described.map((entry) => entry.descriptor);
|
||||
return c.json({ modules } satisfies HostModuleUiInventory, 200);
|
||||
} catch {
|
||||
return sanitizedInventoryFailure(c);
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
|
||||
/**
|
||||
* The platform-owned URL space for published module UI artifacts.
|
||||
*
|
||||
* Deliberately **not** nested under `/api/v1/modules/<module-id>`, and that is a security decision
|
||||
* rather than a stylistic one. A module may declare any route path matching Core's own grammar
|
||||
* (`moduleRoutePathSchema`, `@ekklesia/contracts`), and `ui` is a perfectly legal lowercase static
|
||||
* segment — so a module registering `GET /ui/:version` would be mounted at exactly
|
||||
* `/api/v1/modules/<module-id>/ui/<version>`. Publishing there would put the platform and installed
|
||||
* modules in the same URL namespace, with Hono's registration order silently deciding which one
|
||||
* answers. A sibling prefix can never collide: every module route begins `/api/v1/modules/`, and
|
||||
* nothing the platform serves here does.
|
||||
*
|
||||
* It stays under `/api/v1/*` so it inherits the application's one CORS policy (exact reflected
|
||||
* trusted origins, never a wildcard) — which a cross-origin browser `import()` of this URL requires
|
||||
* — instead of introducing a second, separately-configured public URL space.
|
||||
*/
|
||||
export const MODULE_UI_ASSET_PREFIX = "/api/v1/module-ui";
|
||||
|
||||
/**
|
||||
* Revalidate-always, with a strong `ETag` doing the actual work.
|
||||
*
|
||||
* The URL carries module id and exact installed version, which is *identity*, not *immutability*.
|
||||
* Ekklesia has no installer, no content addressing, and no integrity verification, so nothing stops
|
||||
* an operator from rebuilding version `1.2.3` with different bytes and restarting — the ordinary
|
||||
* inner-loop of developing a module. `immutable` (or any non-zero `max-age`) would therefore be a
|
||||
* promise the platform cannot keep, and a browser that cached a superseded artifact under a reused
|
||||
* version would have no way to recover.
|
||||
*
|
||||
* `no-cache` is not `no-store`: the response is still cached, it just has to be revalidated. With
|
||||
* the `ETag` below, an unchanged artifact costs one conditional request and a `304`, and a changed
|
||||
* one is picked up immediately. A version bump changes the URL and never collides at all. When a
|
||||
* real installer can guarantee that an installed version's bytes never change, this is the single
|
||||
* constant that would move to immutable caching.
|
||||
*/
|
||||
export const MODULE_UI_CACHE_CONTROL = "no-cache";
|
||||
|
||||
/** Modern ESM is served as `text/javascript`, the one JavaScript type the WHATWG MIME sniffing
|
||||
* standard designates as canonical; `application/javascript` is a legacy alias. A browser refuses
|
||||
* to evaluate an ES module served as anything else, so this is a correctness requirement, not a
|
||||
* courtesy. */
|
||||
export const MODULE_UI_CONTENT_TYPE = "text/javascript; charset=utf-8";
|
||||
|
||||
export interface ModuleUiIdentity {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one canonical way to turn an installed module's identity into the browser URL its UI artifact
|
||||
* is published at. Every producer of that URL — this package's own route registration, a future
|
||||
* module-metadata endpoint, and a future Dashboard host — goes through here, so the URL shape is
|
||||
* defined once instead of being re-spelled as a template string in each of them.
|
||||
*
|
||||
* The result is a **root-relative browser URL, never a filesystem path** — the distinction ADR 0024
|
||||
* built the loader around. A host on the API's own origin passes it to `loadModuleUi` unchanged; a
|
||||
* host on a different origin resolves it against the same API base URL it already needs for
|
||||
* `ModuleUiApiClient` (`new URL(moduleUiArtifactUrl(identity), apiBaseUrl)`). It is deliberately not
|
||||
* absolute: the API's public origin is deployment configuration, and baking one in here would break
|
||||
* both split-origin deployments and reverse proxies.
|
||||
*
|
||||
* This function accepts **identity only**. There is no parameter for a filename, a relative path,
|
||||
* or any other artifact selector, so no caller — however careless — can use it to reach a second
|
||||
* file in a module package. Both components are percent-encoded, so even a value that never came
|
||||
* from a validated manifest can only ever produce one extra path segment's worth of text, never a
|
||||
* new segment, a traversal, a query, or a fragment. A value that needed encoding will simply fail
|
||||
* to resolve against the publication registry.
|
||||
*/
|
||||
export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||
return `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(identity.moduleId)}/${encodeURIComponent(
|
||||
identity.version,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
* which modules an installation runs and at which versions. Collapsing them means a caller must
|
||||
* already know both the exact module id and the exact version to learn anything at all.
|
||||
*/
|
||||
function notPublished(c: Context): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "MODULE_UI_NOT_PUBLISHED" }, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* The artifact was validated at discovery and is not retrievable now — deleted, replaced by a
|
||||
* directory, or unreadable. That is a server-side condition rather than a bad request, and often a
|
||||
* transient one (an operator mid-upgrade), so it reports like every other "could not be determined"
|
||||
* result in this application: a sanitized 503 with a fixed code. Never carries the artifact path,
|
||||
* the module directory, the `errno`, or the caught error.
|
||||
*/
|
||||
function artifactUnavailable(c: Context): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }, 503);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9110 §13.1.2 evaluation of `If-None-Match`: `*` matches any existing representation, and
|
||||
* every listed entity-tag is compared with the **weak** comparison function, so a client echoing
|
||||
* `W/"…"` still matches the strong tag this route issued. Anything unparsable simply fails to
|
||||
* match, which costs a full 200 and can never produce a wrongly-fresh 304.
|
||||
*/
|
||||
function isNotModified(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;
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
* every byte it serves from this registry and nothing else. */
|
||||
readonly registry: ModuleUiPublicationRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes installed modules' validated `entrypoints.ui` artifacts at a platform-owned browser
|
||||
* URL, closing the one gap between Core's server-side artifact path and the generic loader's
|
||||
* browser-side URL (ADR 0024, ADR 0025).
|
||||
*
|
||||
* ## The request never touches the filesystem
|
||||
*
|
||||
* `: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
|
||||
* becomes a static file root, and only the single artifact its manifest declares is reachable.
|
||||
*
|
||||
* ## No authentication, deliberately
|
||||
*
|
||||
* Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an
|
||||
* operator reviewed and installed; it carries no credential, and the host — not the artifact —
|
||||
* supplies the principal and permissions at mount time. Every module API stays behind
|
||||
* `requirePermission` against the composed platform registry, and nothing about fetching this file
|
||||
* changes a single authorization decision.
|
||||
*
|
||||
* Requiring a session would also break the thing it is meant to protect: a browser's dynamic
|
||||
* `import()` uses `credentials: "same-origin"`, so a cross-origin import (the split-origin
|
||||
* deployments `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support) sends no cookie and would fail
|
||||
* — a failure that tests green on a server and only shows up in a real browser, exactly the class of
|
||||
* error ADR 0024 exists to prevent. Working around it would mean fetching the bytes and importing a
|
||||
* `blob:` URL, which costs a host its `script-src` CSP.
|
||||
*
|
||||
* The honest cost is stated in ADR 0025: an anonymous caller who already knows a module id **and**
|
||||
* its exact installed version can download that module's UI bundle. That is information disclosure,
|
||||
* not authority, and it is the reason the failure responses above are indistinguishable.
|
||||
*
|
||||
* ## The artifact is never executed
|
||||
*
|
||||
* This route reads bytes and writes bytes. It does not `import()`, evaluate, transform, minify,
|
||||
* re-bundle, or rewrite the artifact, and a module UI whose top level throws is served exactly like
|
||||
* any other. Core's rule that discovery never executes `entrypoints.ui` (ADR 0021) is unchanged, and
|
||||
* publication does not become a second, quieter way to run browser code on the server.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
return serveArtifact(c, published);
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type DiscoveredModule,
|
||||
resolveModulesDirectory,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||
@@ -55,10 +56,18 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
export interface PrepareStartupModuleInventoryOptions {
|
||||
/**
|
||||
* The deployment's `EKKLESIA_MODULES_DIR`, raw, exactly as host configuration read it (ADR 0048).
|
||||
* A production input, not a test seam: the executable entry point always passes it. Unset means
|
||||
* the repository's own `modules/`; set means that absolute directory, which must exist. It is
|
||||
* resolved by Core's one resolver, so an unusable value fails startup with a sanitized
|
||||
* `ModuleDiscoveryError` before any pool exists.
|
||||
*/
|
||||
readonly configuredModulesDirectory?: string | undefined;
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
||||
* modules. Never set by the executable entry point — production always discovers the real
|
||||
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
|
||||
* real repository's own `package.json` files regardless of this override. */
|
||||
* modules, taking precedence over `configuredModulesDirectory`, which is then never resolved.
|
||||
* Never set by the executable entry point. Platform and Module SDK versions are always read from
|
||||
* the real repository's own `package.json` files regardless of this override. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
||||
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
||||
@@ -109,6 +118,11 @@ export async function prepareStartupModuleInventory(
|
||||
}
|
||||
|
||||
const repositoryRoot = resolveRepositoryRoot();
|
||||
// Chosen before anything is read, so an unusable configured directory fails first and alone.
|
||||
const modulesDirectory =
|
||||
options.modulesDirectoryOverride === undefined
|
||||
? resolveModulesDirectory({ configured: options.configuredModulesDirectory, repositoryRoot })
|
||||
: { modulesDirectory: options.modulesDirectoryOverride, modulesDirectoryRequired: false };
|
||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||
@@ -116,7 +130,7 @@ export async function prepareStartupModuleInventory(
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
|
||||
...modulesDirectory,
|
||||
platformVersion,
|
||||
});
|
||||
|
||||
|
||||
+584
-58
@@ -1,26 +1,138 @@
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
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 {
|
||||
type AnyModuleRuntimeServices,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleEligibilityResolution,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
resolveModuleEligibility,
|
||||
type StartedModuleRuntime,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import {
|
||||
createModuleStorageLookup,
|
||||
createS3StorageProvider,
|
||||
type StorageProvider,
|
||||
} from "@ekklesia/core/storage";
|
||||
import { type ServerType, serve } from "@hono/node-server";
|
||||
import type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import {
|
||||
type ClientAddressResolver,
|
||||
createClientAddressResolver,
|
||||
wrapFetchWithClientAddress,
|
||||
} from "./client-address.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;
|
||||
const operationTimeoutMs = 10_000;
|
||||
|
||||
export interface NodeServerTimeouts {
|
||||
readonly headersTimeoutMs: number;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly keepAliveTimeoutMs: number;
|
||||
/**
|
||||
* How often Node re-scans open connections for a header/request timeout violation
|
||||
* (`http.Server`'s `connectionsCheckingInterval`). This is not merely a performance knob: Node
|
||||
* enforces `headersTimeout`/`requestTimeout` only when this periodic check runs, and its own
|
||||
* default (30s) is verified (`node --version` 24.15.0, direct `http.createServer` reproduction)
|
||||
* to let a violating connection sit for up to a full additional interval past its actual
|
||||
* timeout before Node ever closes it — so leaving this at Node's default would silently widen
|
||||
* both timeouts above by up to 30s in the worst case. Kept small relative to `headersTimeoutMs`
|
||||
* so a violation is detected promptly after it actually elapses.
|
||||
*/
|
||||
readonly connectionsCheckingIntervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finite Node HTTP server timeouts for this JSON-only API. Node's own defaults
|
||||
* (`headersTimeout`: 60s, `requestTimeout`: 300s) are generous enough to let a slow-header or
|
||||
* slow-body connection hold a socket for minutes with no reverse proxy in front; these values bound
|
||||
* that exposure while remaining comfortable for the small JSON payloads this API ever accepts (see
|
||||
* `./body-limits.js`). `headersTimeoutMs` must not exceed `requestTimeoutMs` — Node validates this
|
||||
* relationship itself at server construction — so headers alone may take up to 20s, and the
|
||||
* complete request (headers plus body) up to 30s, before Node itself responds `408` and closes the
|
||||
* connection without ever invoking a handler (subject to `connectionsCheckingIntervalMs`'s own
|
||||
* detection latency, above). `keepAliveTimeoutMs` matches Node's own default (5s) made explicit and
|
||||
* typed rather than left implicit; it bounds only the idle window between requests on a reused
|
||||
* connection, never an in-flight one.
|
||||
*/
|
||||
const PRODUCTION_NODE_SERVER_TIMEOUTS: NodeServerTimeouts = Object.freeze({
|
||||
connectionsCheckingIntervalMs: 5_000,
|
||||
headersTimeoutMs: 20_000,
|
||||
keepAliveTimeoutMs: 5_000,
|
||||
requestTimeoutMs: 30_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* The two fixed operational lines a transport-level client failure may produce. Neither ever
|
||||
* carries the remote address, request path, query, headers, cookies, body, forwarding chain, the
|
||||
* caught error's own message, or the malformed bytes that caused it — an attacker controls all of
|
||||
* those, and a parse failure's message in particular is derived directly from the rejected input.
|
||||
*/
|
||||
const NODE_SERVER_TIMEOUT_LOG_MESSAGE =
|
||||
"[server] A connection was closed for exceeding a configured header/request timeout.";
|
||||
const NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE =
|
||||
"[server] A connection was closed after a malformed or oversized client request.";
|
||||
|
||||
/**
|
||||
* Node's own documented default `clientError` responses, reproduced byte-for-byte (verified
|
||||
* directly against the pinned Node 24 runtime by driving each case through a real socket with no
|
||||
* listener attached). Registering *any* `clientError` listener suppresses that default entirely —
|
||||
* `_http_server.js` only falls back when `server.emit("clientError", ...)` returns `false` — so a
|
||||
* listener that merely observes would silently turn every 408/400/431 into a bare connection reset.
|
||||
* Reproducing the exact bytes is what lets the platform log the event without changing what a
|
||||
* caller actually receives.
|
||||
*/
|
||||
const NODE_CLIENT_ERROR_RESPONSES: Readonly<Record<string, string>> = Object.freeze({
|
||||
ERR_HTTP_REQUEST_TIMEOUT: "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n",
|
||||
HPE_HEADER_OVERFLOW: "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
|
||||
});
|
||||
const NODE_CLIENT_ERROR_DEFAULT_RESPONSE = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
|
||||
|
||||
/**
|
||||
* Mirrors Node's own `socketOnError` fallback exactly: write the code-appropriate response only
|
||||
* when the socket is still writable *and* no response has already begun for the in-flight request,
|
||||
* then destroy the socket. The `_httpMessage` guard is what prevents a second, corrupting status
|
||||
* line when a handler already responded and only the (unread) request body subsequently timed out.
|
||||
*/
|
||||
function writeNodeClientErrorResponse(error: NodeJS.ErrnoException, socket: Socket): void {
|
||||
const inFlightResponse = (socket as Socket & { _httpMessage?: { headersSent?: boolean } })
|
||||
._httpMessage;
|
||||
const alreadyResponding = inFlightResponse?.headersSent === true;
|
||||
if (socket.writable && !alreadyResponding) {
|
||||
const code = typeof error.code === "string" ? error.code : "";
|
||||
socket.write(NODE_CLIENT_ERROR_RESPONSES[code] ?? NODE_CLIENT_ERROR_DEFAULT_RESPONSE);
|
||||
}
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
export interface RunningApi {
|
||||
readonly app: Hono;
|
||||
readonly server: ServerType;
|
||||
@@ -31,14 +143,49 @@ 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;
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||
/**
|
||||
* 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;
|
||||
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
|
||||
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
|
||||
readonly configuredModulesDirectory?: string | undefined;
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
|
||||
* over `configuredModulesDirectory`. Never set by the executable entry point. See
|
||||
* `prepareStartupModuleInventory`. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
||||
* filesystem discovery entirely — this exact list drives both permission composition and module
|
||||
* runtime startup. Never set by the executable entry point. See
|
||||
* `prepareStartupModuleInventory`. */
|
||||
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||
/** Test-only escape hatch: narrows the Node header/request/keep-alive timeouts so a transport
|
||||
* test can prove real timeout behavior deterministically without waiting out the production
|
||||
* values. Never set by the executable entry point. */
|
||||
readonly nodeServerTimeoutOverride?: Partial<NodeServerTimeouts>;
|
||||
/** Test-only escape hatch: narrows the graceful HTTP drain deadline
|
||||
* (`HTTP_DRAIN_TIMEOUT_MS`) so a forced close can be proven in milliseconds. Never set by the
|
||||
* executable entry point. */
|
||||
readonly drainTimeoutMsOverride?: number;
|
||||
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
||||
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
||||
readonly moduleHookTimeoutMsOverride?: number;
|
||||
/**
|
||||
* Where deployment-supplied module secrets come from (ADR 0029) — the one *production* dependency
|
||||
* in this options object rather than a test-only escape hatch.
|
||||
*
|
||||
* It is a dependency rather than a field on `ApiRuntimeConfig` because it is a lookup, not a
|
||||
* validated value, and because the set of secrets depends on which modules are discovered — which
|
||||
* is not known when configuration is validated. The executable entry point supplies an
|
||||
* environment-backed source; omitting it supplies nothing, so a module declaring a required secret
|
||||
* is reported `secret_missing` rather than silently starting without it.
|
||||
*/
|
||||
readonly moduleSecrets?: ModuleSecretSource;
|
||||
}
|
||||
|
||||
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||
@@ -50,6 +197,31 @@ 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> {
|
||||
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`, used only while unwinding a startup
|
||||
* failure that already occurred after the module runtime started — never during normal shutdown,
|
||||
* where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */
|
||||
@@ -61,14 +233,146 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
||||
/**
|
||||
* The platform's fixed graceful-drain deadline. In-flight HTTP work has this long to finish after
|
||||
* the listener stops accepting new connections; after it, connections are closed so process
|
||||
* shutdown stays bounded. Not configurable in this slice.
|
||||
*/
|
||||
export const HTTP_DRAIN_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* A shutdown that did not complete cleanly. Carries only fixed booleans and the already-sanitized
|
||||
* module-runtime error — never a request, socket, URL, credential, SQL, or stack detail — so an
|
||||
* operator can see *which* stages failed without any of it becoming a disclosure channel.
|
||||
*/
|
||||
export class ApiShutdownError extends Error {
|
||||
readonly drainTimedOut: boolean;
|
||||
readonly moduleStopFailed: boolean;
|
||||
readonly moduleStopError: unknown;
|
||||
|
||||
constructor(drainTimedOut: boolean, moduleStopError: unknown) {
|
||||
super("The API did not shut down cleanly.");
|
||||
this.name = "ApiShutdownError";
|
||||
this.drainTimedOut = drainTimedOut;
|
||||
this.moduleStopFailed = moduleStopError !== undefined;
|
||||
this.moduleStopError = moduleStopError;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops accepting new connections, then waits up to `timeoutMs` for existing HTTP work to finish.
|
||||
*
|
||||
* At the deadline it closes idle and then still-active connections using Node's own supported
|
||||
* public `Server` APIs (`closeIdleConnections`/`closeAllConnections`, both called only after
|
||||
* `server.close()`), and stops waiting for the close callback. No private socket or server field is
|
||||
* touched.
|
||||
*
|
||||
* Force-closing a connection ends the transport. It does **not** prove that arbitrary in-process
|
||||
* handler JavaScript was canceled — a handler promise may still be running and may still settle.
|
||||
* What is guaranteed is that shutdown no longer waits for it, and that the pool closes afterwards,
|
||||
* so its late database work fails.
|
||||
*/
|
||||
/** `ServerType` is a union that also covers HTTP/2 servers, which do not expose these two methods.
|
||||
* `serve()` here always creates a plain `http.Server`, which does; narrowing with a real capability
|
||||
* check keeps that honest instead of asserting a cast. */
|
||||
interface ConnectionClosingServer {
|
||||
closeAllConnections(): void;
|
||||
closeIdleConnections(): void;
|
||||
}
|
||||
|
||||
function supportsConnectionClosing(
|
||||
server: ServerType,
|
||||
): server is ServerType & ConnectionClosingServer {
|
||||
const candidate = server as Partial<ConnectionClosingServer>;
|
||||
return (
|
||||
typeof candidate.closeIdleConnections === "function" &&
|
||||
typeof candidate.closeAllConnections === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function drainHttpServer(
|
||||
server: ServerType,
|
||||
timeoutMs: number,
|
||||
): Promise<"drained" | "forced"> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
try {
|
||||
const outcome = await Promise.race([
|
||||
closed.then(() => "drained" as const),
|
||||
new Promise<"forced">((resolve) => {
|
||||
timer = setTimeout(() => resolve("forced"), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (outcome === "drained") {
|
||||
return "drained";
|
||||
}
|
||||
if (supportsConnectionClosing(server)) {
|
||||
server.closeIdleConnections();
|
||||
server.closeAllConnections();
|
||||
}
|
||||
return "forced";
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
// The close callback may still fire later; nothing awaits it, and it carries no rejection.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the Node HTTP server. Two platform-owned transport concerns are applied at this exact
|
||||
* boundary, below Hono entirely: `wrapFetchWithClientAddress` resolves and sanitizes the caller's
|
||||
* address from the real socket peer before any route (including Better Auth's own rate limiter)
|
||||
* ever sees the request (see `./client-address.js`), and `serverOptions` gives Node finite
|
||||
* header/request/keep-alive timeouts so a slow-header or slow-body connection cannot hold a socket
|
||||
* indefinitely. A connection Node itself closes for exceeding one of these timeouts never reaches
|
||||
* `app.fetch` and never receives a second response — both are Node's own documented behavior for
|
||||
* `headersTimeout`/`requestTimeout`, not something this function re-implements.
|
||||
*/
|
||||
function bindServer(
|
||||
app: Hono,
|
||||
host: string,
|
||||
port: number,
|
||||
clientAddressResolver: ClientAddressResolver,
|
||||
timeouts: NodeServerTimeouts,
|
||||
): Promise<ServerType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const server = serve({ fetch: app.fetch, hostname: host, port }, () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(server);
|
||||
}
|
||||
const server = serve(
|
||||
{
|
||||
fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver),
|
||||
hostname: host,
|
||||
port,
|
||||
serverOptions: {
|
||||
connectionsCheckingInterval: timeouts.connectionsCheckingIntervalMs,
|
||||
headersTimeout: timeouts.headersTimeoutMs,
|
||||
keepAliveTimeout: timeouts.keepAliveTimeoutMs,
|
||||
requestTimeout: timeouts.requestTimeoutMs,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(server);
|
||||
}
|
||||
},
|
||||
);
|
||||
// `headersTimeout`/`requestTimeout` do NOT surface as the server's general `'timeout'` event —
|
||||
// that event only fires for `server.setTimeout()`'s idle-socket timer, which this server never
|
||||
// arms. Verified directly against the pinned Node 24 runtime by driving a real slow-header and
|
||||
// slow-body connection through a real socket: the only server-level event either produces is
|
||||
// `'clientError'`, carrying `err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. Observing the failure
|
||||
// therefore has to happen here, and — because registering a listener suppresses Node's own
|
||||
// default response — this handler must reproduce that default itself.
|
||||
server.on("clientError", (error: NodeJS.ErrnoException, socket: Socket) => {
|
||||
console.error(
|
||||
error.code === "ERR_HTTP_REQUEST_TIMEOUT"
|
||||
? NODE_SERVER_TIMEOUT_LOG_MESSAGE
|
||||
: NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE,
|
||||
);
|
||||
writeNodeClientErrorResponse(error, socket);
|
||||
});
|
||||
server.once("error", () => {
|
||||
if (!settled) {
|
||||
@@ -87,14 +391,14 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
|
||||
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||
* then the Core identity runtime, authorization store, and evaluator.
|
||||
* 4. Start the module runtime from that exact same discovered inventory
|
||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
|
||||
* same services object every module's `start`/`stop` hook receives.
|
||||
* 5. Create the Hono application, mounting the started module runtime's published routes and
|
||||
* passing the exact same services object to every module HTTP handler.
|
||||
* 6. Bind the Node HTTP server.
|
||||
* 3. Construct the Core identity runtime, authorization store, and evaluator from that pool.
|
||||
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||
* rediscovering modules a second time — passing the manifest-selected service map.
|
||||
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
||||
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
|
||||
* 7. Bind the Node HTTP server.
|
||||
*
|
||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
|
||||
@@ -104,16 +408,27 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
||||
* module database facade, identity, authorization) exists before any executable module lifecycle
|
||||
* code runs, and every module route is published only after the entire module runtime has
|
||||
* registered and started successfully, so no request can ever reach a module before every module
|
||||
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
|
||||
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
|
||||
* applies migrations, generates schema, or issues a module database query merely because modules
|
||||
* were discovered — the database must already be migrated through `pnpm migrations:apply`.
|
||||
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
|
||||
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
|
||||
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
|
||||
* there is nothing mounted to check.
|
||||
*
|
||||
* Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration
|
||||
* cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather
|
||||
* than starting every discovered module: the platform cannot know what an administrator decided, and
|
||||
* starting a module they disabled is worse than not starting at all.
|
||||
*
|
||||
* Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can
|
||||
* stop the server, the module runtime, and the shared pool deterministically. Never applies
|
||||
* migrations, generates schema, or issues a module database query merely because modules were
|
||||
* discovered — the database must already be migrated through `pnpm migrations:apply`.
|
||||
*/
|
||||
export async function startApi(
|
||||
config: ApiRuntimeConfig,
|
||||
options: StartApiOptions = {},
|
||||
): Promise<RunningApi> {
|
||||
const inventory = await prepareStartupModuleInventory({
|
||||
configuredModulesDirectory: options.configuredModulesDirectory,
|
||||
...(options.modulesDirectoryOverride === undefined
|
||||
? {}
|
||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||
@@ -125,40 +440,161 @@ export async function startApi(
|
||||
const databaseHandle = createApiDatabase(config.databaseUrl, {
|
||||
connectionTimeoutMs,
|
||||
operationTimeoutMs,
|
||||
// The one validated transport policy reaches this runtime's single physical pool here; the
|
||||
// identity, authorization, and module Drizzle views are all typed views over that same pool,
|
||||
// so TLS never introduces a second connection.
|
||||
transport: config.databaseTransport,
|
||||
});
|
||||
|
||||
let services: ModuleRuntimeServices;
|
||||
// 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 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,
|
||||
});
|
||||
|
||||
// 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.
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
// Read the administrator's enablement decisions and resolve which discovered modules may actually
|
||||
// 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.
|
||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||
databaseHandle.moduleConfiguration,
|
||||
);
|
||||
const moduleSecrets = options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE;
|
||||
let eligibility: ModuleEligibilityResolution;
|
||||
let moduleConfigurationSettings: ModuleConfigurationSettings;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services);
|
||||
// Both axes are read before a single module entrypoint is imported, and resolved together in
|
||||
// one pass (ADR 0029): a module is eligible only if an administrator enabled it, its own
|
||||
// required configuration is satisfiable, and every module it requires is eligible too. The pass
|
||||
// also produces each eligible module's frozen configuration snapshot, so the configuration that
|
||||
// made a module eligible is the identical object it later receives.
|
||||
// Read sequentially rather than concurrently, deliberately: these are two tiny queries, and
|
||||
// racing them would make the reported failure depend on which rejected first. An operator
|
||||
// debugging a broken deployment must get the same error code every time.
|
||||
const lifecycleConfiguration = await moduleLifecycleStore.readConfiguration();
|
||||
const settings = await moduleConfigurationStore.readSettings();
|
||||
moduleConfigurationSettings = settings;
|
||||
eligibility = resolveModuleEligibility(
|
||||
inventory.modules,
|
||||
lifecycleConfiguration,
|
||||
{ secrets: moduleSecrets, settings },
|
||||
{ providerConfigured: storageProvider !== undefined },
|
||||
);
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
|
||||
eligibility.eligibleModules.map((module) => [
|
||||
module.manifest.id,
|
||||
module.manifest.database?.abi === 1
|
||||
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
|
||||
: Object.freeze({ database: databaseHandle.module }),
|
||||
]),
|
||||
);
|
||||
|
||||
// One scoped facade per eligible module, over the exact buckets that module's own manifest
|
||||
// declares. Built from the eligible set so a blocked module never receives storage at all.
|
||||
const moduleStorages = createModuleStorageLookup(
|
||||
storageProvider,
|
||||
config.storage?.bucketPrefix ?? "",
|
||||
eligibility.eligibleModules.map((module) => ({
|
||||
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
|
||||
moduleId: module.manifest.id,
|
||||
})),
|
||||
);
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Only eligible modules are started, and startup remains all-or-nothing across *that* set: an
|
||||
// ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no
|
||||
// routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves
|
||||
// discovery's topological order, so a started module's required dependencies still precede it.
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||
configurations: eligibility.configurations,
|
||||
storages: moduleStorages,
|
||||
...(options.moduleHookTimeoutMsOverride === undefined
|
||||
? {}
|
||||
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||
});
|
||||
} 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({
|
||||
@@ -168,21 +604,83 @@ export async function startApi(
|
||||
store: authorizationStore,
|
||||
},
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleRealtime: moduleRuntime.realtime,
|
||||
moduleServices: services,
|
||||
runtime,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||
// 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
|
||||
// eligible set.
|
||||
snapshot: Object.freeze({
|
||||
discoveredModules: inventory.modules,
|
||||
eligibility: eligibility.entries,
|
||||
startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)),
|
||||
}),
|
||||
store: moduleLifecycleStore,
|
||||
},
|
||||
moduleConfiguration: {
|
||||
// The settings this process actually resolved from, so `restartRequired` is a comparison
|
||||
// against what is running rather than a guess.
|
||||
bootSettings: moduleConfigurationSettings,
|
||||
discoveredModules: inventory.modules,
|
||||
secrets: moduleSecrets,
|
||||
snapshots: eligibility.configurations,
|
||||
store: moduleConfigurationStore,
|
||||
},
|
||||
moduleStorage: {
|
||||
providerConfigured: storageProvider !== undefined,
|
||||
snapshots: moduleStorages,
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
dashboardHostAssets,
|
||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||
health: {
|
||||
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
|
||||
isModuleStartupComplete: () => !shuttingDown,
|
||||
},
|
||||
devices: {
|
||||
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
||||
// base URL rather than from any request. Production is https by configuration policy, so a
|
||||
// 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;
|
||||
}
|
||||
|
||||
const clientAddressResolver = createClientAddressResolver(config.clientAddress);
|
||||
const timeouts: NodeServerTimeouts = {
|
||||
...PRODUCTION_NODE_SERVER_TIMEOUTS,
|
||||
...options.nodeServerTimeoutOverride,
|
||||
};
|
||||
|
||||
let server: ServerType;
|
||||
try {
|
||||
server = await bindServer(app, config.host, config.port);
|
||||
server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts);
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
@@ -192,7 +690,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
|
||||
@@ -200,22 +700,36 @@ export async function startApi(
|
||||
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
|
||||
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
|
||||
// database cleanup still always runs first, so a stop failure never leaves the pool open.
|
||||
const drainTimeoutMs = options.drainTimeoutMsOverride ?? HTTP_DRAIN_TIMEOUT_MS;
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
shuttingDown = true;
|
||||
shutdownPromise = (async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
// 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 {
|
||||
await moduleRuntime.stop();
|
||||
} catch (error) {
|
||||
moduleStopError = error;
|
||||
}
|
||||
// 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);
|
||||
if (moduleStopError) {
|
||||
throw moduleStopError;
|
||||
// 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,
|
||||
// as fixed metadata: a forced drain is a genuine shutdown failure, not a success.
|
||||
if (drainOutcome === "forced" || moduleStopError) {
|
||||
throw new ApiShutdownError(
|
||||
drainOutcome === "forced",
|
||||
moduleStopError === undefined ? undefined : moduleStopError,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -250,35 +764,47 @@ export function installShutdownSignalHandlers(
|
||||
process.on("SIGTERM", handleSignal);
|
||||
}
|
||||
|
||||
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;
|
||||
readonly modulesDirectory: string | undefined;
|
||||
} {
|
||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||
const trustedOrigins = trustedOriginsValue
|
||||
?.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
|
||||
const host = loadHostConfiguration(processEnvironment);
|
||||
return {
|
||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||
environment: host.environment,
|
||||
input: {
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
host: process.env.EKKLESIA_API_HOST,
|
||||
identity: {
|
||||
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
||||
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
||||
trustedOrigins,
|
||||
},
|
||||
port: process.env.EKKLESIA_API_PORT,
|
||||
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,
|
||||
modulesDirectory: host.modulesDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const { environment, input } = readConfigFromEnvironment();
|
||||
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
|
||||
process.env,
|
||||
);
|
||||
const config = createApiRuntimeConfig(input, { environment });
|
||||
const running = await startApi(config);
|
||||
const running = await startApi(config, {
|
||||
configuredModulesDirectory: modulesDirectory,
|
||||
moduleSecrets,
|
||||
});
|
||||
|
||||
const address = running.server.address();
|
||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+645
-7
@@ -1,19 +1,41 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
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 LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleUiPublicationRegistry,
|
||||
StagedModuleRealtimeRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 { 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";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -26,9 +48,9 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
||||
};
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||
* never real database behavior, so no PostgreSQL is required. */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
@@ -42,6 +64,19 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
/** No module publishes UI in these wiring tests, so the publication route is mounted with an
|
||||
* empty registry built by Core's own factory — never a hand-written stub, so the route under test
|
||||
* always sees the real policy object production hands it. */
|
||||
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
|
||||
@@ -61,7 +96,7 @@ function fakeAuthorization(overrides: {
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
...overrides.store,
|
||||
@@ -69,11 +104,35 @@ 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();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -83,14 +142,233 @@ 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({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -109,6 +387,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -129,6 +414,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -151,6 +443,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -173,6 +472,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -192,6 +498,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -207,9 +520,180 @@ describe("Ekklesia API Hono application", () => {
|
||||
expect(JSON.stringify(body)).not.toContain("postgresql://");
|
||||
});
|
||||
|
||||
it("forces Cache-Control: no-store on every Better Auth response, including a successful one", async () => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })),
|
||||
});
|
||||
const app = 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],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
describe("auth request body limit", () => {
|
||||
it("accepts a request exactly at the auth body limit", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = 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],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", {
|
||||
body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES),
|
||||
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES) },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects a request one byte over the auth body limit with a sanitized 413, never invoking Better Auth", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = 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],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", {
|
||||
body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES + 1),
|
||||
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1) },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized declared Content-Length from its header alone, without invoking Better Auth", async () => {
|
||||
// A declared-oversized Content-Length is rejected synchronously from the header value itself
|
||||
// (`hono/body-limit`'s pinned implementation never reads the stream for this case) — proven
|
||||
// here by the outcome (413, Better Auth never invoked) rather than by instrumenting the
|
||||
// stream, since this in-memory test harness's own `Request`/body plumbing may touch the
|
||||
// stream for reasons unrelated to `bodyLimit`'s own logic.
|
||||
const runtime = fakeRuntime();
|
||||
const app = 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],
|
||||
});
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("a"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES * 10) },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized streamed body with no declared Content-Length", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = 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],
|
||||
});
|
||||
const chunk = new Uint8Array(1024).fill(97);
|
||||
let enqueued = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (enqueued > AUTH_REQUEST_MAX_BODY_BYTES) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
enqueued += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -225,6 +709,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -242,6 +733,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -258,6 +756,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -272,6 +777,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -293,6 +805,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -317,6 +836,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -344,6 +870,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
};
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: evaluatorAuthorize },
|
||||
@@ -362,6 +895,13 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -376,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE,
|
||||
createClientAddressResolver,
|
||||
wrapFetchWithClientAddress,
|
||||
} from "../src/client-address.js";
|
||||
|
||||
function fakeEnv(remoteAddress: string | undefined) {
|
||||
return {
|
||||
incoming: { socket: { remoteAddress } },
|
||||
outgoing: {},
|
||||
} as unknown as Parameters<typeof wrapFetchWithClientAddress>[0] extends (
|
||||
request: Request,
|
||||
env: infer Env,
|
||||
) => unknown
|
||||
? Env
|
||||
: never;
|
||||
}
|
||||
|
||||
describe("wrapFetchWithClientAddress", () => {
|
||||
it("resolves the trusted address from the real socket peer and sets the internal header before calling the wrapped callback", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||
|
||||
const request = new Request("https://example.test/api/auth/sign-in/email");
|
||||
await wrapped(request, fakeEnv("203.0.113.9"));
|
||||
|
||||
expect(wrappedFetch).toHaveBeenCalledTimes(1);
|
||||
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
it("overwrites — never appends to — a caller-supplied copy of the internal header", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||
|
||||
const request = new Request("https://example.test/api/auth/sign-in/email", {
|
||||
headers: { [TRUSTED_CLIENT_ADDRESS_HEADER]: "203.0.113.250" },
|
||||
});
|
||||
await wrapped(request, fakeEnv("192.0.2.1"));
|
||||
|
||||
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("192.0.2.1");
|
||||
// A single value, never a comma-joined "caller-value, resolved-value" — set(), not append().
|
||||
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).not.toContain(",");
|
||||
});
|
||||
|
||||
it("returns a sanitized 503 and never invokes the wrapped callback when resolution fails", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrappedFetch = vi.fn().mockResolvedValue(new Response("should not be reached"));
|
||||
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const response = await wrapped(
|
||||
new Request("https://example.test/api/auth/sign-in/email"),
|
||||
fakeEnv(undefined),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(wrappedFetch).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never logs the peer address, headers, or request path on a resolution failure", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrapped = wrapFetchWithClientAddress(vi.fn(), resolver);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await wrapped(
|
||||
new Request("https://example.test/api/auth/sign-in/email", {
|
||||
headers: { cookie: "super-secret-session", "x-forwarded-for": "203.0.113.1" },
|
||||
}),
|
||||
fakeEnv("not-a-valid-ip"),
|
||||
);
|
||||
|
||||
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
|
||||
expect(emitted).not.toContain("super-secret-session");
|
||||
expect(emitted).not.toContain("203.0.113.1");
|
||||
expect(emitted).not.toContain("sign-in");
|
||||
expect(emitted).not.toContain("not-a-valid-ip");
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the request method, body, and other headers unrelated to the internal address header", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||
|
||||
const request = new Request("https://example.test/api/auth/sign-up/email", {
|
||||
body: JSON.stringify({ email: "[email protected]" }),
|
||||
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
|
||||
method: "POST",
|
||||
});
|
||||
await wrapped(request, fakeEnv("192.0.2.1"));
|
||||
|
||||
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||
expect(forwardedRequest.method).toBe("POST");
|
||||
expect(forwardedRequest.headers.get("origin")).toBe("https://dashboard.example.test");
|
||||
expect(await forwardedRequest.text()).toBe(JSON.stringify({ email: "[email protected]" }));
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -21,10 +21,17 @@ describe("API runtime configuration", () => {
|
||||
const config = createApiRuntimeConfig(validInput(), { environment: "production" });
|
||||
|
||||
expect(config).toEqual({
|
||||
clientAddress: { mode: "direct" },
|
||||
// Production defaults to certificate-verified TLS with no configuration at all.
|
||||
databaseTransport: { mode: "verify-full" },
|
||||
databaseUrl: validInput().databaseUrl,
|
||||
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"],
|
||||
},
|
||||
@@ -131,6 +138,187 @@ describe("API runtime configuration", () => {
|
||||
expect(config.identity.baseUrl).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("defaults public signup to disabled and threads an explicit development enablement through to identity config", () => {
|
||||
expect(createApiRuntimeConfig(validInput()).identity.publicSignupEnabled).toBe(false);
|
||||
expect(
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
...validInput(),
|
||||
identity: {
|
||||
baseUrl: "http://localhost:3000",
|
||||
publicSignupEnabled: true,
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
},
|
||||
},
|
||||
{ environment: "development" },
|
||||
).identity.publicSignupEnabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a production runtime configuration that enables public signup, before any resource is constructed", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
...validInput(),
|
||||
identity: { ...validInput().identity, publicSignupEnabled: true },
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
|
||||
});
|
||||
|
||||
it("defaults client-address configuration to direct mode and threads explicit trusted-proxy configuration through", () => {
|
||||
expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" });
|
||||
|
||||
const config = createApiRuntimeConfig({
|
||||
...validInput(),
|
||||
clientAddress: {
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
},
|
||||
});
|
||||
expect(config.clientAddress).toEqual({
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
});
|
||||
});
|
||||
|
||||
describe("PostgreSQL transport policy", () => {
|
||||
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
|
||||
expect(createApiRuntimeConfig(validInput()).databaseTransport).toEqual({
|
||||
mode: "verify-full",
|
||||
});
|
||||
expect(
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
...validInput(),
|
||||
identity: {
|
||||
baseUrl: "http://localhost:3000",
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
},
|
||||
},
|
||||
{ environment: "development" },
|
||||
).databaseTransport,
|
||||
).toEqual({ mode: "disable" });
|
||||
});
|
||||
|
||||
it("refuses a production configuration that disables TLS", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{ ...validInput(), databaseTransport: { mode: "disable" } },
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
|
||||
});
|
||||
|
||||
it("rejects a malformed or loosely spelled TLS mode in every environment, without coercion", () => {
|
||||
for (const mode of [
|
||||
"VERIFY-FULL",
|
||||
" verify-full ",
|
||||
"require",
|
||||
"verify-ca",
|
||||
"prefer",
|
||||
"no-verify",
|
||||
"true",
|
||||
true,
|
||||
1,
|
||||
"",
|
||||
]) {
|
||||
for (const environment of ["production", "development"] as const) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
...validInput(),
|
||||
databaseTransport: { mode },
|
||||
...(environment === "development"
|
||||
? {
|
||||
identity: {
|
||||
baseUrl: "http://localhost:3000",
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{ environment },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected mode ${JSON.stringify(mode)} to be rejected`).toMatchObject({
|
||||
code: "DATABASE_TLS_MODE_INVALID",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a database URL carrying its own TLS parameters, which the pinned driver would let override the policy", () => {
|
||||
for (const parameter of [
|
||||
"sslmode=disable",
|
||||
"sslmode=require",
|
||||
"sslmode=verify-full",
|
||||
"ssl=true",
|
||||
"sslrootcert=/tmp/ca.crt",
|
||||
"sslcert=/tmp/c.crt",
|
||||
"sslkey=/tmp/c.key",
|
||||
"uselibpqcompat=true",
|
||||
]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
...validInput(),
|
||||
databaseUrl: `postgresql://ekklesia:[email protected]:5432/ekklesia?${parameter}`,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
|
||||
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("never leaks the database URL, credentials, host, or CA material through a transport error", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
...validInput(),
|
||||
databaseTransport: { certificateAuthority: "-----BEGIN CERTIFICATE-----SECRET" },
|
||||
databaseUrl:
|
||||
"postgresql://admin:[email protected]:5432/ekklesia?sslmode=disable",
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}\n${JSON.stringify(captured)}`;
|
||||
expect(publicText).not.toContain("hunter2");
|
||||
expect(publicText).not.toContain("db.internal.example");
|
||||
expect(publicText).not.toContain("SECRET");
|
||||
expect(publicText).not.toContain("sslmode");
|
||||
});
|
||||
});
|
||||
|
||||
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
|
||||
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
|
||||
let captured: unknown;
|
||||
@@ -149,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 ModuleRouteHandler,
|
||||
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,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} 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(): LegacyModuleRuntimeServices {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
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,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { DashboardConfiguration } from "@ekklesia/core/dashboard";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { 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,
|
||||
} 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";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const viewerId = parseEkklesiaUserId("33333333-3333-4333-8333-333333333333");
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly withUi?: boolean;
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, fixture.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-${fixture.id}`,
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
);
|
||||
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.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 8,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||
);
|
||||
if (fixture.withUi !== false) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
readonly fixtures?: readonly ModuleFixture[];
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly platformOwner?: boolean;
|
||||
readonly configuration?: Partial<DashboardConfiguration>;
|
||||
}
|
||||
|
||||
const defaultFixtures: readonly ModuleFixture[] = [
|
||||
{ id: "video", name: "Video" },
|
||||
{ id: "livestream", name: "Livestream" },
|
||||
{ id: "calendar", name: "Calendar" },
|
||||
];
|
||||
|
||||
async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dashboard-routes-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
for (const fixture of options.fixtures ?? defaultFixtures) {
|
||||
await createModule(modulesDirectory, fixture);
|
||||
}
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async () => false },
|
||||
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const store = {
|
||||
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;
|
||||
|
||||
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 LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
return { app };
|
||||
}
|
||||
|
||||
function signedIn(): EkklesiaPrincipal {
|
||||
return createAuthenticatedPrincipal(viewerId);
|
||||
}
|
||||
|
||||
interface NavigationEntry {
|
||||
readonly name: string;
|
||||
readonly module: {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly ui: { readonly url: string };
|
||||
readonly api: { readonly baseUrl: string };
|
||||
readonly permissions: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface NavigationBody {
|
||||
readonly groups: ReadonlyArray<{
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly order: number;
|
||||
readonly modules: readonly NavigationEntry[];
|
||||
}>;
|
||||
readonly ungrouped: readonly NavigationEntry[];
|
||||
}
|
||||
|
||||
async function navigationOf(app: Hono): Promise<NavigationBody> {
|
||||
const response = await app.request(DASHBOARD_NAVIGATION_PATH);
|
||||
expect(response.status).toBe(200);
|
||||
return (await response.json()) as NavigationBody;
|
||||
}
|
||||
|
||||
/** An administrator: a platform owner, who passes the evaluator's owner bypass for the restricted
|
||||
* manage permission exactly as in production. */
|
||||
async function administrator(configuration: Partial<DashboardConfiguration> = {}) {
|
||||
return install({ configuration, platformOwner: true, principal: signedIn() });
|
||||
}
|
||||
|
||||
function mutate(app: Hono, method: "PUT" | "DELETE", url: string, body?: unknown) {
|
||||
return app.request(url, {
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
|
||||
headers: {
|
||||
origin: trustedOrigin,
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
},
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Dashboard navigation — reading", () => {
|
||||
it("shows every mountable module as ungrouped when nothing is configured", async () => {
|
||||
const { app } = await install({ principal: signedIn() });
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(navigation.groups).toEqual([]);
|
||||
// A fresh installation is immediately usable: no initialization write, no manual database edit.
|
||||
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||
"Calendar",
|
||||
"Livestream",
|
||||
"Video",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders the module's own manifest name, never its id", async () => {
|
||||
const { app } = await install({ principal: signedIn() });
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(navigation.ungrouped.map((entry) => entry.name)).not.toContain("livestream");
|
||||
expect(navigation.ungrouped.find((entry) => entry.module.id === "livestream")?.name).toBe(
|
||||
"Livestream",
|
||||
);
|
||||
});
|
||||
|
||||
it("groups and orders exactly as the stored configuration says", async () => {
|
||||
const { app } = await install({
|
||||
configuration: {
|
||||
groups: [
|
||||
{ id: "planning", label: "Planning", sortOrder: 20 },
|
||||
{ id: "media", label: "Media", sortOrder: 10 },
|
||||
],
|
||||
placements: [
|
||||
{ groupId: "media", moduleId: "livestream", sortOrder: 20 },
|
||||
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
|
||||
],
|
||||
},
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(
|
||||
navigation.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
|
||||
).toEqual([
|
||||
["Media", ["Video", "Livestream"]],
|
||||
["Planning", ["Calendar"]],
|
||||
]);
|
||||
expect(navigation.ungrouped).toEqual([]);
|
||||
});
|
||||
|
||||
it("embeds the generic mount descriptor unchanged inside each entry", async () => {
|
||||
const { app } = await install({ principal: signedIn() });
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
const livestream = navigation.ungrouped.find((entry) => entry.module.id === "livestream");
|
||||
|
||||
// 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",
|
||||
});
|
||||
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]);
|
||||
});
|
||||
|
||||
it("agrees exactly with the generic inventory about what is mountable", async () => {
|
||||
const { app } = await install({
|
||||
configuration: {
|
||||
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||
},
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
modules: ReadonlyArray<{ id: string }>;
|
||||
};
|
||||
|
||||
const navigationModules = [
|
||||
...navigation.groups.flatMap((group) => group.modules),
|
||||
...navigation.ungrouped,
|
||||
].map((entry) => entry.module);
|
||||
// Both derive from one publication registry through one shared description, so they cannot
|
||||
// disagree about what exists or about any URL.
|
||||
expect(navigationModules.map((module) => module.id).sort()).toEqual(
|
||||
inventory.modules.map((module) => module.id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the generic inventory free of presentation metadata", async () => {
|
||||
const { app } = await install({ principal: signedIn() });
|
||||
|
||||
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
|
||||
|
||||
// The host-neutral contract a standalone host consumes must not learn Ekklesia has folders or
|
||||
// display names. That layering is the whole reason navigation is a separate route.
|
||||
for (const field of ["name", "label", "group", "order", "ungrouped"]) {
|
||||
expect(body, field).not.toContain(`"${field}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("omits a module that is no longer installed, without breaking navigation", async () => {
|
||||
const { app } = await install({
|
||||
configuration: {
|
||||
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||
placements: [
|
||||
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||
{ groupId: "media", moduleId: "removed-module", sortOrder: 20 },
|
||||
],
|
||||
},
|
||||
fixtures: [{ id: "video", name: "Video" }],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(navigation.groups[0]?.modules.map((entry) => entry.module.id)).toEqual(["video"]);
|
||||
expect(JSON.stringify(navigation)).not.toContain("removed-module");
|
||||
});
|
||||
|
||||
it("keeps a newly installed module visible alongside an existing arrangement", async () => {
|
||||
const { app } = await install({
|
||||
configuration: {
|
||||
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||
},
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
|
||||
// Calendar and Livestream were never configured and are still reachable.
|
||||
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Livestream"]);
|
||||
});
|
||||
|
||||
it("excludes a module that ships no UI, exactly like the inventory does", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{ id: "video", name: "Video" },
|
||||
{ id: "roster", name: "Roster", withUi: false },
|
||||
],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(JSON.stringify(navigation)).not.toContain("Roster");
|
||||
});
|
||||
|
||||
it("leaks no filesystem path, package root, or manifest internal", async () => {
|
||||
const { app } = await install({ principal: signedIn() });
|
||||
|
||||
const body = await (await app.request(DASHBOARD_NAVIGATION_PATH)).text();
|
||||
|
||||
expect(body).not.toContain(tmpdir());
|
||||
expect(body).not.toContain("dist");
|
||||
expect(body).not.toContain("@test/module-");
|
||||
expect(body).not.toContain("ekklesia.module.json");
|
||||
expect(body).not.toContain("entrypoints");
|
||||
expect(body).not.toContain("manifestVersion");
|
||||
});
|
||||
|
||||
it("is per-principal, uncached, and refused to anonymous callers", async () => {
|
||||
const { app: anonymous } = await install();
|
||||
const { app } = await install({ principal: signedIn() });
|
||||
|
||||
const denied = await anonymous.request(DASHBOARD_NAVIGATION_PATH);
|
||||
const allowed = await app.request(DASHBOARD_NAVIGATION_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(denied.status).toBe(403);
|
||||
expect(await denied.text()).not.toContain("Livestream");
|
||||
expect(allowed.headers.get("cache-control")).toBe("no-store");
|
||||
const vary = allowed.headers.get("vary")?.toLowerCase() ?? "";
|
||||
expect(vary).toContain("origin");
|
||||
expect(vary).toContain("cookie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard navigation — organizing", () => {
|
||||
it("creates a group and places a module, and both persist into the resolved view", async () => {
|
||||
const { app } = await administrator();
|
||||
|
||||
expect(
|
||||
(
|
||||
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||
label: "Media",
|
||||
order: 10,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||
groupId: "media",
|
||||
order: 10,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(navigation.groups.map((group) => group.label)).toEqual(["Media"]);
|
||||
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
|
||||
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Video"]);
|
||||
});
|
||||
|
||||
it("renames a group without moving anything", async () => {
|
||||
const { app } = await administrator({
|
||||
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||
placements: [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }],
|
||||
});
|
||||
|
||||
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||
label: "Sunday Production",
|
||||
order: 10,
|
||||
});
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(navigation.groups[0]).toMatchObject({ id: "media", label: "Sunday Production" });
|
||||
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
|
||||
});
|
||||
|
||||
it("moves a module between groups and changes only its placement", async () => {
|
||||
const { app } = await administrator({
|
||||
groups: [
|
||||
{ id: "media", label: "Media", sortOrder: 10 },
|
||||
{ id: "sunday-production", label: "Sunday Production", sortOrder: 5 },
|
||||
],
|
||||
placements: [
|
||||
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
|
||||
{ groupId: "media", moduleId: "video", sortOrder: 20 },
|
||||
],
|
||||
});
|
||||
const before = await navigationOf(app);
|
||||
const livestreamBefore = before.groups
|
||||
.flatMap((group) => group.modules)
|
||||
.find((entry) => entry.module.id === "livestream");
|
||||
|
||||
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||
groupId: "sunday-production",
|
||||
order: 10,
|
||||
});
|
||||
const after = await navigationOf(app);
|
||||
const livestreamAfter = after.groups
|
||||
.flatMap((group) => group.modules)
|
||||
.find((entry) => entry.module.id === "livestream");
|
||||
|
||||
// The one property this whole pass exists to guarantee: the module — its artifact URL, its API
|
||||
// root, its permissions, its version, its name — is byte-identical across the move.
|
||||
expect(livestreamAfter).toEqual(livestreamBefore);
|
||||
expect(
|
||||
after.groups.find((group) => group.id === "sunday-production")?.modules.map((e) => e.name),
|
||||
).toEqual(["Livestream"]);
|
||||
expect(after.groups.find((group) => group.id === "media")?.modules.map((e) => e.name)).toEqual([
|
||||
"Video",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ungroups a module deliberately", async () => {
|
||||
const { app } = await administrator({
|
||||
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||
});
|
||||
|
||||
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
|
||||
groupId: null,
|
||||
order: 5,
|
||||
});
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(navigation.groups[0]?.modules).toEqual([]);
|
||||
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||
"Video",
|
||||
"Calendar",
|
||||
"Livestream",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ungroups a deleted group's modules rather than losing them", async () => {
|
||||
const { app } = await administrator({
|
||||
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||
});
|
||||
|
||||
const deleted = await mutate(app, "DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`);
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
expect(deleted.status).toBe(204);
|
||||
expect(navigation.groups).toEqual([]);
|
||||
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||
"Video",
|
||||
"Calendar",
|
||||
"Livestream",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a placement naming a group that does not exist", async () => {
|
||||
const { app } = await administrator();
|
||||
|
||||
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
|
||||
groupId: "not-a-group",
|
||||
order: 10,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "DASHBOARD_GROUP_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("refuses a body carrying anything beyond the declared fields", async () => {
|
||||
const { app } = await administrator();
|
||||
|
||||
const bodies: readonly unknown[] = [
|
||||
{ label: "Media", order: 10, parentGroupId: "root" },
|
||||
{ label: "Media", order: 10, ownerModuleId: "video" },
|
||||
{ label: "Media" },
|
||||
{ label: "", order: 10 },
|
||||
{ label: "Media", order: -1 },
|
||||
{ label: "Media", order: 1.5 },
|
||||
{ label: 42, order: 10 },
|
||||
[],
|
||||
null,
|
||||
];
|
||||
for (const body of bodies) {
|
||||
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, body);
|
||||
expect(response.status, JSON.stringify(body)).toBe(400);
|
||||
expect(await response.json(), JSON.stringify(body)).toEqual({ code: "INVALID_REQUEST" });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a malformed group identifier in the path", async () => {
|
||||
const { app } = await administrator();
|
||||
|
||||
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/Not%20Valid`, {
|
||||
label: "Media",
|
||||
order: 10,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard navigation — authority", () => {
|
||||
it("refuses organizing to a signed-in user without the manage permission", async () => {
|
||||
// Reading works for any signed-in user; organizing does not.
|
||||
const { app } = await install({ principal: signedIn() });
|
||||
|
||||
expect((await app.request(DASHBOARD_NAVIGATION_PATH)).status).toBe(200);
|
||||
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||
label: "Media",
|
||||
order: 10,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
});
|
||||
|
||||
it("refuses organizing from an untrusted origin, before authorization even runs", async () => {
|
||||
const { app } = await administrator();
|
||||
|
||||
const response = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("refuses organizing to an anonymous caller", async () => {
|
||||
const { app } = await install();
|
||||
|
||||
for (const [method, url, body] of [
|
||||
["PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10 }],
|
||||
["DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, undefined],
|
||||
["PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 0 }],
|
||||
] as const) {
|
||||
const response = await mutate(app, method, url, body);
|
||||
expect(response.status, `${method} ${url}`).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants no module authority to an administrator who reorganizes the Dashboard", async () => {
|
||||
// The platform owner holds `core.dashboard.navigation.manage` through the owner bypass. That
|
||||
// says nothing about any module: the hints in the navigation view are still evaluated per
|
||||
// module permission, and every module route stays enforced on its own.
|
||||
const { app } = await administrator();
|
||||
|
||||
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||
label: "Media",
|
||||
order: 10,
|
||||
});
|
||||
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||
groupId: "media",
|
||||
order: 10,
|
||||
});
|
||||
const navigation = await navigationOf(app);
|
||||
|
||||
const livestream = navigation.groups[0]?.modules[0];
|
||||
// The fixture modules declare no permissions at all, so an owner still holds none for them:
|
||||
// moving a module into a folder cannot manufacture a permission hint, let alone a grant.
|
||||
expect(livestream?.module.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes no method the navigation contract does not define", async () => {
|
||||
const { app } = await administrator();
|
||||
|
||||
for (const method of ["POST", "PATCH"] as const) {
|
||||
const response = await app.request(DASHBOARD_NAVIGATION_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method,
|
||||
});
|
||||
expect(response.status, method).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.2.0", platform: "^0.2.0" },
|
||||
entrypoints: {
|
||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
id: "announcements",
|
||||
manifestVersion: 8,
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createAuthenticatedPrincipal,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { createGrantsRoutes } from "../src/grants-routes.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
@@ -49,7 +49,7 @@ function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationSt
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
...overrides,
|
||||
@@ -58,6 +58,7 @@ function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationSt
|
||||
|
||||
function buildApp(options: {
|
||||
readonly allowed?: boolean;
|
||||
readonly now?: () => Date;
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly store?: AuthorizationStore;
|
||||
}) {
|
||||
@@ -71,6 +72,7 @@ function buildApp(options: {
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
registry: CORE_PERMISSION_REGISTRY,
|
||||
runtime,
|
||||
store,
|
||||
@@ -102,15 +104,20 @@ describe("GET /api/v1/grants", () => {
|
||||
userId: subjectUserId,
|
||||
},
|
||||
];
|
||||
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) });
|
||||
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue({ grants }) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
const body = (await response.json()) as { grants: unknown[] };
|
||||
const body = (await response.json()) as { grants: unknown[]; nextCursor: string | null };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.grants).toHaveLength(1);
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" });
|
||||
expect(body.nextCursor).toBeNull();
|
||||
// The default page size is applied server-side; `sourceKind` filtering stays server-side too.
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||
limit: 50,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing or malformed userId query parameter", async () => {
|
||||
@@ -183,7 +190,6 @@ describe("POST /api/v1/grants", () => {
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
id: grantId,
|
||||
permission: registeredPermission,
|
||||
scope: parseAuthorizationScope("calendar-event:123"),
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
@@ -191,11 +197,7 @@ describe("POST /api/v1/grants", () => {
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({
|
||||
permission: registeredPermission,
|
||||
scope: "calendar-event:123",
|
||||
subjectUserId,
|
||||
}),
|
||||
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -207,11 +209,14 @@ describe("POST /api/v1/grants", () => {
|
||||
expect.objectContaining({
|
||||
actor: { type: "user", userId: manager.userId },
|
||||
permission: registeredPermission,
|
||||
scope: "calendar-event:123",
|
||||
sourceKind: "explicit",
|
||||
userId: subjectUserId,
|
||||
}),
|
||||
);
|
||||
// The generic HTTP surface creates unscoped explicit grants only.
|
||||
expect(store.createGrant).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ scope: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
|
||||
@@ -378,3 +383,499 @@ describe("POST /api/v1/grants/:grantId/revoke", () => {
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core grant mutation request body limit", () => {
|
||||
it("accepts a comfortably-under-limit grant-creation body and rejects one byte over the exact limit", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const overSizedBody = "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1);
|
||||
const overResponse = await app.request("/api/v1/grants", {
|
||||
body: overSizedBody,
|
||||
headers: {
|
||||
"content-length": String(overSizedBody.length),
|
||||
"content-type": "application/json",
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(overResponse.status).toBe(413);
|
||||
expect(await overResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
|
||||
// A well-formed, comfortably-under-limit payload still succeeds through the same route.
|
||||
const underResponse = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(underResponse.status).not.toBe(413);
|
||||
});
|
||||
|
||||
it("rejects an oversized declared Content-Length on grant creation without invoking permission evaluation", async () => {
|
||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
|
||||
const evaluator: AuthorizationEvaluator = { authorize: evaluatorAuthorize };
|
||||
const store = fakeStore();
|
||||
const app = new Hono();
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator,
|
||||
registry: CORE_PERMISSION_REGISTRY,
|
||||
runtime: fakeRuntime(),
|
||||
store,
|
||||
trustedOrigins: new Set([trustedOrigin]),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: "a",
|
||||
headers: {
|
||||
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES * 10),
|
||||
"content-type": "application/json",
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(evaluatorAuthorize).not.toHaveBeenCalled();
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized streamed grant-creation body with no declared Content-Length", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
const chunk = new Uint8Array(1024).fill(97);
|
||||
let enqueued = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
enqueued += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
const chunk = new Uint8Array(1024).fill(97);
|
||||
let pullCount = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pullCount += 1;
|
||||
if (pullCount > 3) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also bounds the revoke mutation body", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
|
||||
headers: {
|
||||
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 3 request-contract regressions. Every case here asserts both the sanitized public response
|
||||
* *and* that the store was never reached, because the point of each fix is that a malformed or
|
||||
* unsupported request is decided at the request boundary rather than turning into database work or
|
||||
* an indistinguishable infrastructure failure.
|
||||
*/
|
||||
describe("explicit-grant request invariants", () => {
|
||||
const validExpiry = "2099-01-01T00:00:00.000Z";
|
||||
|
||||
async function post(app: Hono, body: unknown) {
|
||||
return await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects a scope field in any form, before any store mutation", async () => {
|
||||
// Core keeps scoped-grant semantics for trusted internal callers, but no HTTP middleware
|
||||
// supplies a scope to the evaluator, so a scope accepted here could never authorize anything.
|
||||
for (const scope of ["calendar-event:123", "", " ", null, 42, {}, []]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await post(app, { permission: registeredPermission, scope, subjectUserId });
|
||||
|
||||
expect(response.status, `scope ${JSON.stringify(scope)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts exactly one canonical expiration representation", async () => {
|
||||
const createdGrant = {
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
expiresAt: new Date(validExpiry),
|
||||
id: grantId,
|
||||
permission: registeredPermission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await post(app, {
|
||||
expiresAt: validExpiry,
|
||||
permission: registeredPermission,
|
||||
subjectUserId,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(store.createGrant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: new Date(validExpiry) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects normalized-impossible, locale, offset, imprecise, padded, and past expirations", async () => {
|
||||
const now = new Date("2026-07-26T12:00:00.000Z");
|
||||
for (const expiresAt of [
|
||||
// Normalized impossible date: `new Date` silently rewrote this to March 3rd.
|
||||
"2026-02-31T00:00:00.000Z",
|
||||
"2026-13-01T00:00:00.000Z",
|
||||
// Locale-shaped and date-only forms.
|
||||
"March 5, 2027",
|
||||
"2027-01-01",
|
||||
// Missing milliseconds, excess fractional precision, offsets, lowercase separators.
|
||||
"2027-01-01T00:00:00Z",
|
||||
"2027-01-01T00:00:00.123456Z",
|
||||
"2027-01-01T00:00:00.000+02:00",
|
||||
"2027-01-01t00:00:00.000z",
|
||||
"2027-01-01T00:00:00.000+00:00",
|
||||
// Whitespace padding and trailing text.
|
||||
" 2027-01-01T00:00:00.000Z",
|
||||
"2027-01-01T00:00:00.000Z ",
|
||||
"2027-01-01T00:00:00.000Z extra",
|
||||
// Past and exactly-now are both dead on arrival.
|
||||
"2020-01-01T00:00:00.000Z",
|
||||
"2026-07-26T12:00:00.000Z",
|
||||
// Non-strings.
|
||||
42,
|
||||
null,
|
||||
true,
|
||||
]) {
|
||||
const { app, store } = buildApp({ now: () => now });
|
||||
const response = await post(app, {
|
||||
expiresAt,
|
||||
permission: registeredPermission,
|
||||
subjectUserId,
|
||||
});
|
||||
|
||||
expect(response.status, `expiresAt ${JSON.stringify(expiresAt)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a sanitized 409 for an equivalent grant that is already effective", async () => {
|
||||
const store = fakeStore({
|
||||
createGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await post(app, { permission: registeredPermission, subjectUserId });
|
||||
const raw = await response.text();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
// The body is the stable code and nothing else — no existing grant id, actor, scope, source
|
||||
// reference, expiry, SQL, constraint name, or driver text.
|
||||
expect(Object.keys(JSON.parse(raw))).toEqual(["code"]);
|
||||
for (const leaked of [
|
||||
grantId,
|
||||
manager.userId,
|
||||
subjectUserId,
|
||||
"calendar-event",
|
||||
"constraint",
|
||||
"select ",
|
||||
"insert ",
|
||||
"ekklesia_core_grant",
|
||||
"Error",
|
||||
"at ",
|
||||
]) {
|
||||
expect(raw, `must not leak ${leaked}`).not.toContain(leaked);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-canonical grant id on revoke without ever calling the store", async () => {
|
||||
for (const raw of [
|
||||
"not-a-uuid",
|
||||
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
|
||||
"%200b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
|
||||
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
|
||||
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f700",
|
||||
"0b36b1b69a294c4e9e2d4c0e4c5e6f70",
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants/${raw}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status, `grantId ${raw}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("still returns the sanitized 404 for a canonical but unknown grant id", async () => {
|
||||
const store = fakeStore({
|
||||
revokeGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||
expect(store.revokeGrant).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit-grant listing pagination", () => {
|
||||
function grantAt(index: number, iso: string) {
|
||||
return {
|
||||
createdAt: new Date(iso),
|
||||
id: parseGrantId(`00000000-0000-4000-8000-${String(index).padStart(12, "0")}`),
|
||||
permission: registeredPermission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
}
|
||||
|
||||
it("applies the default limit of 50 and accepts the 1 and 100 boundaries", async () => {
|
||||
for (const [query, expected] of [
|
||||
["", 50],
|
||||
["&limit=1", 1],
|
||||
["&limit=50", 50],
|
||||
["&limit=100", 100],
|
||||
] as const) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}${query}`);
|
||||
|
||||
expect(response.status, query).toBe(200);
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||
limit: expected,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects every non-canonical or out-of-range limit before any store work", async () => {
|
||||
for (const limit of [
|
||||
"0",
|
||||
"101",
|
||||
"-1",
|
||||
"+5",
|
||||
"1.0",
|
||||
"1.5",
|
||||
"01",
|
||||
"005",
|
||||
" 5",
|
||||
"5 ",
|
||||
"1e2",
|
||||
"",
|
||||
"abc",
|
||||
"0x10",
|
||||
"Infinity",
|
||||
"NaN",
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&limit=${encodeURIComponent(limit)}`,
|
||||
);
|
||||
|
||||
expect(response.status, `limit ${JSON.stringify(limit)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown and duplicated query keys", async () => {
|
||||
for (const query of [
|
||||
`userId=${subjectUserId}&sort=asc`,
|
||||
`userId=${subjectUserId}&offset=10`,
|
||||
`userId=${subjectUserId}&sourceKind=relationship`,
|
||||
`userId=${subjectUserId}&userId=${subjectUserId}`,
|
||||
`userId=${subjectUserId}&limit=5&limit=6`,
|
||||
`userId=${subjectUserId}&cursor=a&cursor=b`,
|
||||
`userId=${subjectUserId}&cursor=`,
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants?${query}`);
|
||||
|
||||
expect(response.status, query).toBe(400);
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a cursor only when another page exists, and traverses without duplicates or omissions", async () => {
|
||||
// Three rows sharing one `created_at`, which is exactly the case a timestamp-only keyset
|
||||
// boundary would skip or repeat.
|
||||
const sameInstant = "2026-01-01T00:00:00.000Z";
|
||||
const all = [grantAt(3, sameInstant), grantAt(2, sameInstant), grantAt(1, sameInstant)];
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
do {
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi.fn().mockImplementation((_userId, listOptions) => {
|
||||
const startIndex =
|
||||
listOptions.after === undefined
|
||||
? 0
|
||||
: all.findIndex((grant) => grant.id === listOptions.after.id) + 1;
|
||||
const slice = all.slice(startIndex, startIndex + listOptions.limit);
|
||||
const more = startIndex + listOptions.limit < all.length;
|
||||
const last = slice.at(-1);
|
||||
return Promise.resolve({
|
||||
grants: slice,
|
||||
...(more && last ? { nextBoundary: { createdAt: last.createdAt, id: last.id } } : {}),
|
||||
});
|
||||
}),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
const url: string = `/api/v1/grants?userId=${subjectUserId}&limit=1${
|
||||
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
|
||||
}`;
|
||||
const response = await app.request(url);
|
||||
const body = (await response.json()) as {
|
||||
grants: { id: string }[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body.grants).toHaveLength(1);
|
||||
seen.push(body.grants[0]?.id as string);
|
||||
cursor = body.nextCursor;
|
||||
pages += 1;
|
||||
} while (cursor !== null && pages < 10);
|
||||
|
||||
expect(pages).toBe(3);
|
||||
expect(seen).toEqual(all.map((grant) => grant.id));
|
||||
expect(new Set(seen).size).toBe(3);
|
||||
// The final page ends the traversal explicitly.
|
||||
expect(cursor).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a cursor issued for a different subject, and every malformed encoding", async () => {
|
||||
const boundary = { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId };
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({
|
||||
grants: [grantAt(1, boundary.createdAt.toISOString())],
|
||||
nextBoundary: boundary,
|
||||
}),
|
||||
});
|
||||
const { app: issuingApp } = buildApp({ store });
|
||||
const issued = (await (
|
||||
await issuingApp.request(`/api/v1/grants?userId=${subjectUserId}&limit=1`)
|
||||
).json()) as { nextCursor: string };
|
||||
expect(issued.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u);
|
||||
|
||||
const encode = (payload: unknown) =>
|
||||
Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
const canonical = {
|
||||
g: grantId,
|
||||
t: "2026-01-01T00:00:00.000Z",
|
||||
u: subjectUserId,
|
||||
v: 1,
|
||||
};
|
||||
|
||||
for (const [label, cursor] of [
|
||||
// A cursor minted for a different subject must not be replayable against this one.
|
||||
["other subject", encode({ ...canonical, u: "someone_else_1" })],
|
||||
["wrong version", encode({ ...canonical, v: 2 })],
|
||||
["extra field", encode({ ...canonical, extra: "x" })],
|
||||
["missing field", encode({ g: canonical.g, t: canonical.t, u: canonical.u })],
|
||||
["invalid timestamp", encode({ ...canonical, t: "2026-01-01" })],
|
||||
["non-canonical timestamp", encode({ ...canonical, t: "2026-01-01T00:00:00Z" })],
|
||||
["invalid grant id", encode({ ...canonical, g: "not-a-uuid" })],
|
||||
["array payload", encode([canonical])],
|
||||
["not json", Buffer.from("nonsense", "utf8").toString("base64url")],
|
||||
["padded base64", `${Buffer.from(JSON.stringify(canonical), "utf8").toString("base64")}`],
|
||||
["malformed base64url", "!!!not-base64!!!"],
|
||||
["trailing data", `${encode(canonical)}AA`],
|
||||
] as const) {
|
||||
const { app, store: pagedStore } = buildApp({});
|
||||
const response = await app.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(cursor)}`,
|
||||
);
|
||||
const raw = await response.text();
|
||||
|
||||
expect(response.status, label).toBe(400);
|
||||
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
|
||||
// A rejected cursor reveals nothing it decoded, and does zero store/database work.
|
||||
expect(raw).not.toContain(grantId);
|
||||
expect(raw).not.toContain("someone_else_1");
|
||||
expect(pagedStore.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
// The cursor the API itself issued is accepted.
|
||||
const { app: acceptingApp, store: acceptingStore } = buildApp({});
|
||||
const accepted = await acceptingApp.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(issued.nextCursor)}`,
|
||||
);
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(acceptingStore.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||
after: boundary,
|
||||
limit: 50,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleRouteHandler,
|
||||
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,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} 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(): LegacyModuleRuntimeServices {
|
||||
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,184 @@
|
||||
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("hands the API the same modules directory Doctor and the operator commands read", () => {
|
||||
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
|
||||
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
|
||||
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||
});
|
||||
|
||||
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() {
|
||||
@@ -48,6 +70,21 @@ function validConfig() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one query behavior every fake pool in this file needs: startup reads the stored module
|
||||
* enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module
|
||||
* entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning
|
||||
* an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database
|
||||
* — not a way to skip the read. The tests that care about a *specific* configuration drive the real
|
||||
* resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the
|
||||
* real store (`core/tests/module-lifecycle-store.integration.ts`).
|
||||
*/
|
||||
function emptyQuery() {
|
||||
return vi
|
||||
.fn()
|
||||
.mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] });
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function createTemporaryModulesDirectory(): Promise<string> {
|
||||
@@ -67,11 +104,11 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 3,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||
permissions: [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
...manifestOverrides,
|
||||
@@ -130,7 +167,12 @@ interface WorkingFixtureModuleOptions {
|
||||
readonly registerThrows?: boolean;
|
||||
readonly startThrows?: boolean;
|
||||
readonly stopThrows?: boolean;
|
||||
/** Test-only: the stop hook records its marker and then never settles, so an API-level deadline
|
||||
* 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
|
||||
@@ -144,17 +186,49 @@ 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",
|
||||
path: "/ping",
|
||||
permission: ${JSON.stringify(permissionId)},
|
||||
maxBodyBytes: 0,
|
||||
handler: async () => new Response("pong", { status: 200 }),
|
||||
});`
|
||||
: "";
|
||||
@@ -173,6 +247,7 @@ async function createWorkingFixtureModule(
|
||||
stop() {
|
||||
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
||||
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
|
||||
${options.stopNeverSettles ? "return new Promise(() => {});" : ""}
|
||||
},
|
||||
};`;
|
||||
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
|
||||
@@ -192,15 +267,16 @@ function fakeDiscoveredModule(
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
id,
|
||||
manifestVersion: 3,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions,
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
@@ -212,8 +288,11 @@ function fakeDiscoveredModule(
|
||||
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
|
||||
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
|
||||
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
|
||||
type ClientErrorHandler = (error: NodeJS.ErrnoException, socket: import("node:net").Socket) => void;
|
||||
|
||||
function fakeServer() {
|
||||
let errorHandler: ((error: Error) => void) | undefined;
|
||||
let clientErrorHandler: ClientErrorHandler | undefined;
|
||||
const server = {
|
||||
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
|
||||
close: vi.fn((callback?: () => void) => {
|
||||
@@ -221,6 +300,12 @@ function fakeServer() {
|
||||
return server;
|
||||
}),
|
||||
listening: true,
|
||||
on: vi.fn((event: string, handler: ClientErrorHandler) => {
|
||||
if (event === "clientError") {
|
||||
clientErrorHandler = handler;
|
||||
}
|
||||
return server;
|
||||
}),
|
||||
once: vi.fn((event: string, handler: (error: Error) => void) => {
|
||||
if (event === "error") {
|
||||
errorHandler = handler;
|
||||
@@ -229,6 +314,9 @@ function fakeServer() {
|
||||
}),
|
||||
};
|
||||
return {
|
||||
/** The `clientError` handler `bindServer` registered — the real event path Node uses for a
|
||||
* header/request timeout, a parse failure, and a header overflow alike. */
|
||||
clientErrorHandler: () => clientErrorHandler,
|
||||
server,
|
||||
triggerError: (error: Error) => errorHandler?.(error),
|
||||
};
|
||||
@@ -239,12 +327,12 @@ function fakeServer() {
|
||||
* resolving synchronously would reference `bindServer`'s own `server` const before it is
|
||||
* initialized. */
|
||||
function mockSuccessfulServe() {
|
||||
const { server, triggerError } = fakeServer();
|
||||
const { clientErrorHandler, server, triggerError } = fakeServer();
|
||||
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
|
||||
queueMicrotask(() => listeningListener?.());
|
||||
return server;
|
||||
});
|
||||
return { server, triggerError };
|
||||
return { clientErrorHandler, server, triggerError };
|
||||
}
|
||||
|
||||
function mockFailingServe(error: Error) {
|
||||
@@ -342,7 +430,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { server } = mockSuccessfulServe();
|
||||
@@ -358,7 +446,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -375,10 +463,40 @@ 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() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { server } = mockSuccessfulServe();
|
||||
@@ -394,7 +512,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("closes an already-created database pool when identity construction fails during startup", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockImplementation(() => {
|
||||
throw new Error("postgresql://admin:[email protected]/x");
|
||||
@@ -409,10 +527,101 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
// The rejection happens in `createApiRuntimeConfig` itself — the same boundary the executable
|
||||
// entry point calls before `startApi` — so `startApi` is never even reachable with such a
|
||||
// configuration, and no runtime resource is ever constructed to leak.
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
publicSignupEnabled: true,
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
expect(poolEnd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
// Both an explicit production plaintext selection and a URL that would silently downgrade the
|
||||
// policy are rejected in `createApiRuntimeConfig` — the same boundary the executable entry
|
||||
// point calls before `startApi` — so no runtime resource is ever constructed to leak.
|
||||
for (const [label, input] of [
|
||||
[
|
||||
"explicit production plaintext",
|
||||
{
|
||||
databaseTransport: { mode: "disable" },
|
||||
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
||||
},
|
||||
],
|
||||
[
|
||||
"connection-string TLS downgrade",
|
||||
{
|
||||
databaseUrl:
|
||||
"postgresql://ekklesia:[email protected]:5432/ekklesia?sslmode=disable",
|
||||
},
|
||||
],
|
||||
] as const) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
port: 0,
|
||||
...input,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${label} to be rejected`).toBeInstanceOf(Error);
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
expect(poolEnd).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockFailingServe(new Error("EADDRINUSE"));
|
||||
@@ -465,6 +674,199 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia API Node server timeouts", () => {
|
||||
beforeEach(() => {
|
||||
poolConstructor.mockReset();
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
serveMock.mockReset();
|
||||
});
|
||||
|
||||
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig());
|
||||
|
||||
expect(serveMock).toHaveBeenCalledTimes(1);
|
||||
const [options] = serveMock.mock.calls[0] as [
|
||||
{
|
||||
serverOptions?: {
|
||||
connectionsCheckingInterval?: number;
|
||||
headersTimeout?: number;
|
||||
keepAliveTimeout?: number;
|
||||
requestTimeout?: number;
|
||||
};
|
||||
},
|
||||
];
|
||||
expect(options.serverOptions?.headersTimeout).toBeGreaterThan(0);
|
||||
expect(options.serverOptions?.requestTimeout).toBeGreaterThan(0);
|
||||
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
|
||||
// Node only enforces headersTimeout/requestTimeout when this periodic check runs (verified
|
||||
// directly against the pinned Node runtime — its own default, 30s, would otherwise let a
|
||||
// violating connection sit for up to an additional 30s past its actual timeout).
|
||||
expect(options.serverOptions?.connectionsCheckingInterval).toBeGreaterThan(0);
|
||||
expect(options.serverOptions?.connectionsCheckingInterval).toBeLessThan(30_000);
|
||||
// Node requires headersTimeout not to exceed requestTimeout.
|
||||
expect(options.serverOptions?.headersTimeout).toBeLessThanOrEqual(
|
||||
options.serverOptions?.requestTimeout ?? 0,
|
||||
);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), {
|
||||
nodeServerTimeoutOverride: { headersTimeoutMs: 50, requestTimeoutMs: 100 },
|
||||
});
|
||||
|
||||
const [options] = serveMock.mock.calls[0] as [
|
||||
{
|
||||
serverOptions?: {
|
||||
headersTimeout?: number;
|
||||
keepAliveTimeout?: number;
|
||||
requestTimeout?: number;
|
||||
};
|
||||
},
|
||||
];
|
||||
expect(options.serverOptions?.headersTimeout).toBe(50);
|
||||
expect(options.serverOptions?.requestTimeout).toBe(100);
|
||||
// The un-overridden keep-alive timeout keeps its production default.
|
||||
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
// Whether Node actually *emits* anything for a real header/request timeout — and that exactly one
|
||||
// 408 is written, the socket closes, and no handler runs — is proven only at the real transport
|
||||
// boundary, by the raw-socket scenarios in `apps/api/tests/api-http.integration.ts`. Node's
|
||||
// general `'timeout'` event is NOT that path (it belongs to `server.setTimeout()`, which this
|
||||
// server never arms); `'clientError'` is. The tests below therefore deliberately do not claim to
|
||||
// prove the event path at all — they cover only the response-reproduction logic that Node's own
|
||||
// default would otherwise have performed, including the duplicate-response guard, which is
|
||||
// impractical to drive from a real socket.
|
||||
function fakeErrorSocket(overrides: { headersSent?: boolean; writable?: boolean } = {}) {
|
||||
const socket = {
|
||||
_httpMessage:
|
||||
overrides.headersSent === undefined ? undefined : { headersSent: overrides.headersSent },
|
||||
destroy: vi.fn(),
|
||||
destroyed: false,
|
||||
writable: overrides.writable ?? true,
|
||||
write: vi.fn(),
|
||||
};
|
||||
return socket as unknown as import("node:net").Socket & {
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
async function startAndCaptureClientErrorHandler() {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { clientErrorHandler } = mockSuccessfulServe();
|
||||
const running = await startApi(validConfig());
|
||||
const handler = clientErrorHandler();
|
||||
if (!handler) {
|
||||
throw new Error("Expected startApi to register a clientError handler.");
|
||||
}
|
||||
return { handler, running };
|
||||
}
|
||||
|
||||
it("reproduces Node's own client-error responses byte-for-byte, since registering a listener suppresses them", async () => {
|
||||
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
for (const [code, expected] of [
|
||||
["ERR_HTTP_REQUEST_TIMEOUT", "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n"],
|
||||
[
|
||||
"HPE_HEADER_OVERFLOW",
|
||||
"HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
|
||||
],
|
||||
["HPE_INVALID_METHOD", "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
|
||||
[undefined, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
|
||||
] as const) {
|
||||
const socket = fakeErrorSocket();
|
||||
const error = Object.assign(new Error("parser detail that must never be logged"), { code });
|
||||
handler(error, socket);
|
||||
|
||||
expect(socket.write).toHaveBeenCalledTimes(1);
|
||||
expect(socket.write).toHaveBeenCalledWith(expected);
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
await running.shutdown();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never writes a second response when a handler already began responding, but still closes the socket", async () => {
|
||||
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const socket = fakeErrorSocket({ headersSent: true });
|
||||
handler(Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }), socket);
|
||||
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// An unwritable socket is likewise never written to, only destroyed.
|
||||
const closedSocket = fakeErrorSocket({ writable: false });
|
||||
handler(
|
||||
Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }),
|
||||
closedSocket,
|
||||
);
|
||||
expect(closedSocket.write).not.toHaveBeenCalled();
|
||||
expect(closedSocket.destroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await running.shutdown();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs one fixed sanitized line per client error, never the caught error's own message", async () => {
|
||||
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
handler(
|
||||
Object.assign(new Error("Parse Error: /secret-path?token=abc123"), {
|
||||
code: "HPE_INVALID_METHOD",
|
||||
}),
|
||||
fakeErrorSocket(),
|
||||
);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
|
||||
expect(emitted).not.toContain("secret-path");
|
||||
expect(emitted).not.toContain("abc123");
|
||||
expect(emitted).not.toContain("Parse Error");
|
||||
expect(emitted).not.toContain("HPE_");
|
||||
expect(emitted).not.toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/u);
|
||||
|
||||
await running.shutdown();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia API module permission composition at startup", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
@@ -590,7 +992,7 @@ describe("Ekklesia API module permission composition at startup", () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -602,6 +1004,73 @@ describe("Ekklesia API module permission composition at startup", () => {
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("discovers installed modules from the configured modules directory (ADR 0048)", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
await createFixtureModule(modulesDirectory, "incompatible", {
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
|
||||
});
|
||||
|
||||
// The module that fails discovery lives only in the configured directory, so this rejection
|
||||
// proves discovery read it rather than the repository's own `modules/`.
|
||||
await expect(
|
||||
startApi(validConfig(), { configuredModulesDirectory: modulesDirectory }),
|
||||
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts with zero modules from an empty configured modules directory", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { configuredModulesDirectory: modulesDirectory });
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a relative path", "relative/private-mount-5b2a", "MODULES_DIRECTORY_INVALID"],
|
||||
["a missing directory", "/nonexistent/private-mount-5b2a", "MODULES_DIRECTORY_UNREADABLE"],
|
||||
])("fails closed on %s, before any pool exists, naming no path", async (_label, configured, code) => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await startApi(validConfig(), { configuredModulesDirectory: configured });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(ModuleDiscoveryError);
|
||||
const issues = (captured as ModuleDiscoveryError).issues;
|
||||
expect(issues.map((issue) => issue.code)).toEqual([code]);
|
||||
expect(JSON.stringify(issues)).not.toContain("private-mount-5b2a");
|
||||
expect((captured as Error).message).not.toContain("private-mount-5b2a");
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets the test-only override take precedence over a configured directory", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
// The configured value is unusable; the override wins, so it is never even resolved.
|
||||
const running = await startApi(validConfig(), {
|
||||
configuredModulesDirectory: "relative/never-resolved",
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
|
||||
const module = await import("../src/platform-permissions.js");
|
||||
|
||||
@@ -626,18 +1095,83 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
serveMock.mockReset();
|
||||
loadDisplayHostAssetsMock.mockReset();
|
||||
vi.mocked(createApiApp).mockClear();
|
||||
});
|
||||
|
||||
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
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();
|
||||
@@ -688,6 +1222,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
method: "GET",
|
||||
path: "/ping",
|
||||
permission: "db-identity.ping.read",
|
||||
maxBodyBytes: 0,
|
||||
handler: async (_request, context) => {
|
||||
${refs.expression}.push(context.services.database);
|
||||
return new Response(null, { status: 204 });
|
||||
@@ -769,11 +1304,14 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
|
||||
});
|
||||
|
||||
// The module whose start hook failed is rolled back first through its own stop hook, then the
|
||||
// module that had already started, in reverse order.
|
||||
expect(recorder.events).toEqual([
|
||||
"one:register",
|
||||
"two:register",
|
||||
"one:start",
|
||||
"two:start",
|
||||
"two:stop",
|
||||
"one:stop",
|
||||
]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
@@ -826,6 +1364,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
recorder.events.push("pool:end");
|
||||
}),
|
||||
on: vi.fn(),
|
||||
query: emptyQuery(),
|
||||
};
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
@@ -879,8 +1418,14 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
|
||||
// Shutdown now aggregates which stages failed; the module-runtime error is carried intact.
|
||||
await expect(running.shutdown()).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
|
||||
drainTimedOut: false,
|
||||
moduleStopError: {
|
||||
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
|
||||
},
|
||||
moduleStopFailed: true,
|
||||
name: "ApiShutdownError",
|
||||
});
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -890,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
});
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
@@ -923,4 +1468,154 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 4 correction: the API composition's own bounded-cleanup guarantees.
|
||||
*
|
||||
* Core proves an individual hook deadline in isolation; these prove that `startApi`/`shutdown`
|
||||
* actually reach pool closure through those deadlines, which is where the platform's promise ("the
|
||||
* pool always closes") is either kept or broken. Every deadline here is a short, test-only
|
||||
* override; production keeps the 10-second hook and 30-second drain defaults.
|
||||
*/
|
||||
describe("Ekklesia API bounded module cleanup", () => {
|
||||
it("bounds a never-settling startup rollback, still rolls back earlier modules, closes the pool, and binds no listener", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
|
||||
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
|
||||
dependencies: { modules: { required: { one: "^0.1.0" } } },
|
||||
startThrows: true,
|
||||
stopNeverSettles: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
|
||||
await expect(
|
||||
startApi(validConfig(), {
|
||||
moduleHookTimeoutMsOverride: 25,
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: [
|
||||
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "two" }),
|
||||
expect.objectContaining({ code: "MODULE_ROLLBACK_TIMEOUT", moduleId: "two" }),
|
||||
],
|
||||
});
|
||||
|
||||
// The failing module's own stop hook was attempted first and timed out; the earlier module
|
||||
// still received its rollback afterwards.
|
||||
expect(recorder.events).toEqual([
|
||||
"one:register",
|
||||
"two:register",
|
||||
"one:start",
|
||||
"two:start",
|
||||
"two:stop",
|
||||
"one:stop",
|
||||
]);
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds a never-settling normal stop, attempts the earlier module, and still closes the pool", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
|
||||
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
|
||||
dependencies: { modules: { required: { one: "^0.1.0" } } },
|
||||
stopNeverSettles: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), {
|
||||
moduleHookTimeoutMsOverride: 25,
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
|
||||
await expect(running.shutdown()).rejects.toMatchObject({
|
||||
drainTimedOut: false,
|
||||
moduleStopError: {
|
||||
issues: [expect.objectContaining({ code: "MODULE_STOP_TIMEOUT", moduleId: "two" })],
|
||||
},
|
||||
moduleStopFailed: true,
|
||||
name: "ApiShutdownError",
|
||||
});
|
||||
expect(recorder.events.slice(-2)).toEqual(["two:stop", "one:stop"]);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Repeated and concurrent callers share the one memoized result and restart nothing.
|
||||
const [first, second] = await Promise.allSettled([running.shutdown(), running.shutdown()]);
|
||||
expect(first.status).toBe("rejected");
|
||||
expect(second.status).toBe("rejected");
|
||||
expect(recorder.events.filter((event) => event === "two:stop")).toHaveLength(1);
|
||||
expect(recorder.events.filter((event) => event === "one:stop")).toHaveLength(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports a drain timeout and a module-stop timeout together, after the pool has closed", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "stalls", recorder, {
|
||||
stopNeverSettles: true,
|
||||
});
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
// A server whose `close` callback never fires stands in for a connection that never drains,
|
||||
// deterministically and without a real socket. `closeIdleConnections`/`closeAllConnections`
|
||||
// are the supported public APIs the platform calls at the deadline.
|
||||
const closeAllConnections = vi.fn();
|
||||
const closeIdleConnections = vi.fn();
|
||||
const { server } = fakeServer();
|
||||
// `close` deliberately never invokes its callback: nothing drains, which is exactly what the
|
||||
// drain deadline exists for — deterministic, with no real socket and no sleep.
|
||||
const stalled = Object.assign(server, {
|
||||
close: vi.fn(() => server),
|
||||
closeAllConnections,
|
||||
closeIdleConnections,
|
||||
});
|
||||
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
|
||||
// Deferred exactly like `mockSuccessfulServe`: `bindServer` resolves with `server`, which
|
||||
// is still in its temporal dead zone during the synchronous `serve()` call.
|
||||
queueMicrotask(() => listeningListener?.());
|
||||
return stalled;
|
||||
});
|
||||
|
||||
const running = await startApi(validConfig(), {
|
||||
drainTimeoutMsOverride: 25,
|
||||
moduleHookTimeoutMsOverride: 25,
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await running.shutdown();
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toMatchObject({
|
||||
drainTimedOut: true,
|
||||
moduleStopFailed: true,
|
||||
name: "ApiShutdownError",
|
||||
});
|
||||
const issues = (captured as { moduleStopError: { issues: Array<{ code: string }> } })
|
||||
.moduleStopError.issues;
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({ code: "MODULE_STOP_TIMEOUT", moduleId: "stalls" }),
|
||||
]);
|
||||
// The platform force-closed through Node's public APIs, then still stopped modules and closed
|
||||
// the pool — neither deadline skipped a later stage.
|
||||
expect(closeIdleConnections).toHaveBeenCalledTimes(1);
|
||||
expect(closeAllConnections).toHaveBeenCalledTimes(1);
|
||||
expect(recorder.events).toContain("stalls:stop");
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Nothing about the request, socket, connection, or module exception leaks.
|
||||
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}\n${JSON.stringify(
|
||||
captured,
|
||||
)}`;
|
||||
for (const secret of ["127.0.0.1", "postgres", "password", "select ", "Error: stop"]) {
|
||||
expect(publicText.toLowerCase()).not.toContain(secret.toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
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 ModuleConfigurationDeclarationInput,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleConfigurationRecord,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleDatabase,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { 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,
|
||||
} from "./support/in-memory-module-configuration.js";
|
||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555");
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The configuration shape the real Announcements product would need as an Ekklesia module: a slide
|
||||
* duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied
|
||||
* storage credential.
|
||||
*
|
||||
* Modelled from the reference repository, never imported from it. Nothing Announcements-specific
|
||||
* exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive
|
||||
* the whole path — manifest, validation, storage, API, snapshot — without the module reaching any
|
||||
* platform infrastructure.
|
||||
*/
|
||||
const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [
|
||||
{
|
||||
key: "slideSeconds",
|
||||
type: "integer",
|
||||
default: 7,
|
||||
minimum: 1,
|
||||
maximum: 600,
|
||||
description: "How long each slide is shown.",
|
||||
},
|
||||
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
|
||||
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
|
||||
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
|
||||
{ key: "storageCredential", type: "secret", required: true },
|
||||
];
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${fixture.id}`,
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
readonly fixtures?: readonly ModuleFixture[];
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly platformOwner?: boolean;
|
||||
readonly grants?: readonly string[];
|
||||
readonly records?: readonly ModuleConfigurationRecord[];
|
||||
readonly secrets?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
||||
readonly store?: ModuleConfigurationStore;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly app: Hono;
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
readonly store: ModuleConfigurationStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
||||
* composition: read the stored settings, resolve eligibility and configuration in one pass, then
|
||||
* build the UI registry and every option from that same resolution. Doing it the same way here is
|
||||
* what makes these assertions evidence about production composition rather than a bespoke wiring.
|
||||
*/
|
||||
async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
for (const fixture of options.fixtures ?? [
|
||||
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||
]) {
|
||||
await createModule(modulesDirectory, fixture);
|
||||
}
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []);
|
||||
const secrets = createFakeModuleSecretSource(options.secrets ?? {});
|
||||
const lifecycleStore = createInMemoryModuleLifecycleStore();
|
||||
const bootSettings = await store.readSettings();
|
||||
const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), {
|
||||
secrets,
|
||||
settings: bootSettings,
|
||||
});
|
||||
|
||||
const held = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
|
||||
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const authorizationStore = {
|
||||
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;
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: {
|
||||
bootSettings,
|
||||
discoveredModules: modules,
|
||||
secrets,
|
||||
snapshots: eligibility.configurations,
|
||||
store,
|
||||
},
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: {
|
||||
discoveredModules: modules,
|
||||
eligibility: eligibility.entries,
|
||||
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
||||
},
|
||||
store: lifecycleStore,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
return { app, modules, store };
|
||||
}
|
||||
|
||||
function owner(): InstallOptions {
|
||||
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
|
||||
}
|
||||
|
||||
interface ConfigurationBody {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly keys: readonly {
|
||||
readonly key: string;
|
||||
readonly type: string;
|
||||
readonly required: boolean;
|
||||
readonly description?: string;
|
||||
readonly default?: string | number | boolean;
|
||||
readonly values?: readonly string[];
|
||||
readonly value?: string;
|
||||
readonly configured: boolean;
|
||||
}[];
|
||||
readonly unknownKeys: readonly string[];
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
|
||||
async function readConfiguration(
|
||||
app: Hono,
|
||||
moduleId = "announcements",
|
||||
): Promise<ConfigurationBody> {
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
return (await response.json()) as ConfigurationBody;
|
||||
}
|
||||
|
||||
function putConfiguration(
|
||||
app: Hono,
|
||||
values: unknown,
|
||||
moduleId = "announcements",
|
||||
origin = trustedOrigin,
|
||||
) {
|
||||
return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||
body: JSON.stringify({ values }),
|
||||
headers: { "content-type": "application/json", origin },
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/v1/module-configuration/:moduleId", () => {
|
||||
it("returns the module's declared schema with defaults and stored values", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.moduleId).toBe("announcements");
|
||||
expect(body.keys.map((entry) => entry.key)).toEqual([
|
||||
"slideSeconds",
|
||||
"pollIntervalSeconds",
|
||||
"bucket",
|
||||
"fit",
|
||||
"storageCredential",
|
||||
]);
|
||||
expect(body.keys[0]).toEqual({
|
||||
configured: false,
|
||||
default: 7,
|
||||
description: "How long each slide is shown.",
|
||||
key: "slideSeconds",
|
||||
required: false,
|
||||
type: "integer",
|
||||
});
|
||||
expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({
|
||||
configured: true,
|
||||
required: true,
|
||||
value: "slides",
|
||||
});
|
||||
expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]);
|
||||
});
|
||||
|
||||
it("never returns a secret value, only whether the deployment supplies one", async () => {
|
||||
const configured = await install({
|
||||
...owner(),
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
const body = await readConfiguration(configured.app);
|
||||
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||
|
||||
expect(secretEntry).toEqual({
|
||||
configured: true,
|
||||
key: "storageCredential",
|
||||
required: true,
|
||||
type: "secret",
|
||||
});
|
||||
// Not redacted, not null, not a placeholder — the field is absent entirely, and the secret
|
||||
// string appears nowhere in the payload.
|
||||
expect(secretEntry).not.toHaveProperty("value");
|
||||
expect(secretEntry).not.toHaveProperty("default");
|
||||
expect(JSON.stringify(body)).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("reports an unsupplied secret as not configured, still without a value field", async () => {
|
||||
const { app } = await install(owner());
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({
|
||||
configured: false,
|
||||
key: "storageCredential",
|
||||
required: true,
|
||||
type: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists stored keys the current manifest no longer declares, without failing", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "removedInThisVersion", moduleId: "announcements", value: "kept" },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
|
||||
expect(body.unknownKeys).toEqual(["removedInThisVersion"]);
|
||||
expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion");
|
||||
});
|
||||
|
||||
it("returns a module declaring no configuration as an empty, valid document", async () => {
|
||||
const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] });
|
||||
const body = await readConfiguration(app, "calendar");
|
||||
|
||||
expect(body.keys).toEqual([]);
|
||||
expect(body.unknownKeys).toEqual([]);
|
||||
expect(body.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a module this process did not discover", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
||||
});
|
||||
|
||||
it("is not cached and varies on origin and cookie", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(response.headers.get("Vary")).toContain("Cookie");
|
||||
});
|
||||
|
||||
it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => {
|
||||
const failing: ModuleConfigurationStore = {
|
||||
readSettings: async () => {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED");
|
||||
},
|
||||
replace: async () => {
|
||||
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED");
|
||||
},
|
||||
};
|
||||
let bootRead = false;
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
store: {
|
||||
readSettings: async () => {
|
||||
if (!bootRead) {
|
||||
bootRead = true;
|
||||
return { records: [] };
|
||||
}
|
||||
return failing.readSettings();
|
||||
},
|
||||
replace: (input) => failing.replace(input),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/v1/module-configuration/:moduleId", () => {
|
||||
it("stores values and reports that a restart is required", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ConfigurationBody;
|
||||
expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({
|
||||
configured: true,
|
||||
value: "12",
|
||||
});
|
||||
// The running process still holds the boot snapshot; claiming otherwise would be a lie.
|
||||
expect(body.restartRequired).toBe(true);
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no pending restart when the submitted document matches what booted", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides" });
|
||||
|
||||
expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the whole document, so an omitted key is cleared", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
],
|
||||
});
|
||||
|
||||
await putConfiguration(app, { bucket: "slides" });
|
||||
|
||||
expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]);
|
||||
});
|
||||
|
||||
it("never touches another module's stored values", async () => {
|
||||
const { app, store } = await install({
|
||||
...owner(),
|
||||
fixtures: [
|
||||
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||
{ configuration: [{ key: "bucket", type: "string" }], id: "calendar" },
|
||||
],
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||
],
|
||||
});
|
||||
|
||||
await putConfiguration(app, { bucket: "renamed" });
|
||||
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "bucket", moduleId: "announcements", value: "renamed" },
|
||||
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses to accept secret material over HTTP", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, {
|
||||
bucket: "slides",
|
||||
storageCredential: "s3cret",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" });
|
||||
// Nothing was written at all — not the secret, and not the valid key beside it.
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a key the module does not declare", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" });
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a string for an integer", { bucket: "slides", slideSeconds: "12" }],
|
||||
["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }],
|
||||
["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }],
|
||||
["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }],
|
||||
["a value outside a declared enum", { bucket: "slides", fit: "stretch" }],
|
||||
["a string shorter than its declared minLength", { bucket: "" }],
|
||||
["a nested object", { bucket: { name: "slides" } }],
|
||||
["an array", { bucket: ["slides"] }],
|
||||
["null", { bucket: null }],
|
||||
])("rejects %s without storing anything", async (_label, values) => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(app, values);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a body with an unexpected top-level key", { values: {}, force: true }],
|
||||
["a body with no values object", {}],
|
||||
["a bare array body", []],
|
||||
["a bare string body", "bucket=slides"],
|
||||
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
])("rejects the reserved property name %s as a submitted key", async (key) => {
|
||||
const { app, store } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: `{"values":{${JSON.stringify(key)}:"polluted"}}`,
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
// Nothing about the request reached `Object.prototype`.
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
// `bucket` is required and absent: an operator may save partial work.
|
||||
const response = await putConfiguration(app, { slideSeconds: 12 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((await store.readSettings()).records).toEqual([
|
||||
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await putConfiguration(
|
||||
app,
|
||||
{ bucket: "slides" },
|
||||
"announcements",
|
||||
untrustedOrigin,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect((await store.readSettings()).records).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing origin", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: JSON.stringify({ values: { bucket: "slides" } }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
||||
const { app } = await install(owner());
|
||||
const oversized = JSON.stringify({
|
||||
values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) },
|
||||
});
|
||||
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
body: oversized,
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("module configuration authority", () => {
|
||||
it("refuses an anonymous caller on both verbs", async () => {
|
||||
const { app } = await install();
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||
expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses an ordinary authenticated user who holds no configuration permission", async () => {
|
||||
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("admits a non-owner holding exactly core.modules.configuration.manage", async () => {
|
||||
const { app } = await install({
|
||||
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not let the lifecycle permission configure a module, or the reverse", async () => {
|
||||
// The two authorities are deliberately separate: tuning how a module behaves and deciding
|
||||
// whether it runs at all are different decisions (ADR 0029).
|
||||
const lifecycleOnly = await install({
|
||||
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
expect(
|
||||
(await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||
).toBe(403);
|
||||
expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
||||
|
||||
const configurationOnly = await install({
|
||||
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403);
|
||||
expect(
|
||||
(await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||
).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration state is visible through the lifecycle resource", () => {
|
||||
it("reports a module blocked by missing required configuration, naming the key not the value", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||
});
|
||||
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
const body = (await response.json()) as {
|
||||
readonly modules: readonly {
|
||||
readonly id: string;
|
||||
readonly runtime: {
|
||||
readonly state: string;
|
||||
readonly reason?: string;
|
||||
readonly configurationKey?: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
expect(body.modules[0]?.runtime).toEqual({
|
||||
configurationKey: "bucket",
|
||||
reason: "configuration_missing",
|
||||
state: "blocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a module blocked by a missing secret, without naming the secret's value", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
const raw = await response.text();
|
||||
|
||||
expect(raw).toContain("secret_missing");
|
||||
expect(raw).toContain("storageCredential");
|
||||
});
|
||||
|
||||
it("keeps a configuration-blocked module out of the mountable UI inventory", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
fixtures: [
|
||||
{ configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" },
|
||||
],
|
||||
});
|
||||
|
||||
const inventory = (await (await app.request("/api/v1/module-ui")).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
|
||||
// Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is
|
||||
// not eligible, so it is not published.
|
||||
expect(inventory.modules).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no secret or configuration value leaks through any surface", () => {
|
||||
it("keeps secret material out of every response body this resource produces", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
secrets: { announcements: { storageCredential: "super-secret-value" } },
|
||||
});
|
||||
|
||||
const listed = await (
|
||||
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
})
|
||||
).text();
|
||||
const mutated = await (await putConfiguration(app, { bucket: "slides" })).text();
|
||||
const lifecycle = await (
|
||||
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
||||
).text();
|
||||
|
||||
for (const payload of [listed, mutated, lifecycle]) {
|
||||
expect(payload).not.toContain("super-secret-value");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not serve a secret value even if a row for that key somehow exists in storage", async () => {
|
||||
// Unreachable through the API, which refuses to write a secret key, and unreachable through the
|
||||
// resolver, which never reads one. This plants the row directly to prove the *serialization*
|
||||
// boundary holds independently: a secret's `value` is omitted by construction, not filtered by a
|
||||
// branch someone could remove.
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [
|
||||
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||
{ key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await readConfiguration(app);
|
||||
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||
|
||||
expect(secretEntry).not.toHaveProperty("value");
|
||||
expect(secretEntry?.configured).toBe(false);
|
||||
expect(JSON.stringify(body)).not.toContain("planted-in-the-database");
|
||||
});
|
||||
|
||||
it("never leaks a filesystem path, package name, or manifest path", async () => {
|
||||
const { app } = await install({
|
||||
...owner(),
|
||||
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||
});
|
||||
|
||||
const listed = await (
|
||||
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
})
|
||||
).text();
|
||||
|
||||
expect(listed).not.toContain("/tmp");
|
||||
expect(listed).not.toContain("@test/module-");
|
||||
expect(listed).not.toContain("ekklesia.module.json");
|
||||
expect(listed).not.toContain("dist/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the configuration prefix is a sibling of the module route namespace", () => {
|
||||
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
|
||||
// `config` and `configuration` are ordinary lowercase static segments under
|
||||
// `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on
|
||||
// `/api/v1/modules/announcements/config`. A sibling prefix cannot collide.
|
||||
expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
||||
expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration");
|
||||
|
||||
const { app } = await install(owner());
|
||||
const collision = await app.request("/api/v1/modules/announcements/config", {
|
||||
body: JSON.stringify({ values: {} }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(collision.status).toBe(404);
|
||||
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "@ekklesia/core/migrations";
|
||||
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
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
|
||||
@@ -211,14 +212,20 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -234,14 +241,21 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: fixtureModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
@@ -250,6 +264,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
expect(aggregate.migrations[1]?.checksum).toBe(
|
||||
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
||||
);
|
||||
expect(aggregate.migrations[2]?.checksum).toBe(
|
||||
"63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036",
|
||||
);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
}
|
||||
|
||||
@@ -263,12 +280,19 @@ async function startMigratedApi(
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
publicSignupEnabled: true,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
{ environment: "production" },
|
||||
// Development validation: this suite signs an owner account up over HTTP to exercise real
|
||||
// authorized module database access, and production configuration refuses to enable public
|
||||
// signup at all until durable abuse controls exist
|
||||
// (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`). `environment`
|
||||
// affects only Ekklesia's own config validation — it never reaches Better Auth — and the base
|
||||
// URL and trusted origins here are identical either way.
|
||||
{ environment: "development" },
|
||||
);
|
||||
return startApi(config, {
|
||||
rateLimitOverride: { max: 1000, window: 1 },
|
||||
@@ -298,6 +322,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 }),
|
||||
@@ -397,6 +442,11 @@ describe.sequential("live API — module database explicit migration ownership",
|
||||
|
||||
const handle = createApiDatabase(database.url);
|
||||
expect(handle.module).toBeDefined();
|
||||
const alpha = handle.moduleExecutor("alpha");
|
||||
const beta = handle.moduleExecutor("beta");
|
||||
expect(alpha).not.toBe(beta);
|
||||
expect(moduleDatabaseExecutorModuleId(alpha)).toBe("alpha");
|
||||
expect(moduleDatabaseExecutorModuleId(beta)).toBe("beta");
|
||||
await handle.close();
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
|
||||
@@ -423,6 +473,13 @@ describe.sequential("live API — module database explicit migration ownership",
|
||||
expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe(
|
||||
1,
|
||||
);
|
||||
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
|
||||
).toBe(1);
|
||||
expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1);
|
||||
} finally {
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
@@ -434,8 +491,15 @@ describe.sequential("live API — module database explicit migration ownership",
|
||||
await applyCoreMigrations(database.url);
|
||||
resetRecorder();
|
||||
|
||||
// The module whose `start` failed is now rolled back through its own `stop` hook first. That
|
||||
// hook also writes to the table its missing migration would have created, so it fails too —
|
||||
// which is exactly the documented reason a `stop` hook must tolerate partial initialization.
|
||||
// Both outcomes are reported, in order, and neither is swallowed.
|
||||
await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" })],
|
||||
issues: [
|
||||
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" }),
|
||||
expect.objectContaining({ code: "MODULE_ROLLBACK_FAILED", moduleId: "db-facade" }),
|
||||
],
|
||||
});
|
||||
|
||||
// No schema inference, no automatic table creation as a side effect of the failed attempt.
|
||||
@@ -472,11 +536,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" });
|
||||
@@ -486,7 +551,20 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
const readHandlerEvent = [...events]
|
||||
.reverse()
|
||||
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||
expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]);
|
||||
// The complete handler-context surface, asserted exactly so a new field is a deliberate change
|
||||
// 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",
|
||||
]);
|
||||
expect(readHandlerEvent?.services).toBeDefined();
|
||||
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { 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: 8,
|
||||
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(): LegacyModuleRuntimeServices {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
moduleRoutesOverlap,
|
||||
type PermissionId,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -16,13 +15,26 @@ import {
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
canonicalizeVaryHeader,
|
||||
mergedVaryHeader,
|
||||
ModuleRouteConfigurationError,
|
||||
mergedVaryHeader,
|
||||
} from "../src/module-http-routes.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -43,21 +55,30 @@ function fakeStore(): AuthorizationStore {
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRoute {
|
||||
return Object.freeze({ ...fields });
|
||||
/** 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 them explicitly to prove the contract itself. */
|
||||
function moduleRoute(
|
||||
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
||||
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
||||
maxBodyBytes?: number;
|
||||
},
|
||||
): RegisteredModuleHttpRoute {
|
||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
|
||||
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
||||
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
||||
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
@@ -71,7 +92,7 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
||||
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
||||
@@ -87,7 +108,7 @@ function buildModuleApp(options: {
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly services?: ModuleRuntimeServices;
|
||||
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
||||
readonly trustedOrigins?: readonly string[];
|
||||
}) {
|
||||
const evaluator = options.evaluator ?? {
|
||||
@@ -98,7 +119,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],
|
||||
});
|
||||
@@ -117,6 +146,36 @@ function permissionRegistry(
|
||||
}
|
||||
|
||||
describe("module HTTP route mounting", () => {
|
||||
it("selects the owning module's stable service entry for its handler", async () => {
|
||||
const calendar = fakeModuleServices();
|
||||
const people = fakeModuleServices();
|
||||
const services: ModuleRuntimeServicesLookup = new Map([
|
||||
["calendar", calendar],
|
||||
["people", people],
|
||||
]);
|
||||
let received: unknown;
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler: (_request, context) => {
|
||||
received = context.services;
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/services",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
services,
|
||||
});
|
||||
|
||||
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
|
||||
expect(received).toBe(calendar);
|
||||
expect(received).not.toBe(people);
|
||||
});
|
||||
|
||||
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
|
||||
const { app } = buildModuleApp({
|
||||
@@ -324,13 +383,20 @@ describe("module route handler adaptation", () => {
|
||||
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||
expect(capturedRequest).toBeInstanceOf(Request);
|
||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
||||
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
|
||||
// or configuration reaches a module handler.
|
||||
// Exactly config, moduleId, params, principal, services, storage — and nothing else. `config`
|
||||
// is this module's own frozen configuration snapshot (ADR 0029) and `storage` its own scoped
|
||||
// object storage (ADR 0030); a Hono Context, the authorization store or evaluator, raw
|
||||
// PostgreSQL, the storage provider, an S3 endpoint or credential, platform configuration, and
|
||||
// another module's configuration or storage all remain unreachable, which is what this
|
||||
// exact-shape assertion exists to keep true.
|
||||
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
||||
"config",
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
"realtime",
|
||||
"services",
|
||||
"storage",
|
||||
]);
|
||||
expect(capturedContext).toMatchObject({
|
||||
moduleId: "calendar",
|
||||
@@ -343,6 +409,14 @@ describe("module route handler adaptation", () => {
|
||||
expect(Object.isFrozen(capturedContext)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.config)).toBe(true);
|
||||
// This module declared no configuration, so its snapshot is empty rather than absent — module
|
||||
// code never has to branch on whether the platform supplied one.
|
||||
expect(capturedContext?.config.keys()).toEqual([]);
|
||||
// Likewise storage: this module declared no buckets, so it gets a frozen facade that refuses
|
||||
// every one rather than `undefined`.
|
||||
expect(Object.isFrozen(capturedContext?.storage)).toBe(true);
|
||||
expect(capturedContext?.storage.buckets()).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives static and root routes a frozen, empty params object", async () => {
|
||||
@@ -465,7 +539,7 @@ describe("module route authorization", () => {
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("does not invoke the handler when principal resolution fails", async () => {
|
||||
@@ -594,7 +668,7 @@ describe("module route trusted-origin enforcement", () => {
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||
@@ -723,7 +797,7 @@ describe("module route CORS", () => {
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("never reflects an untrusted origin for a module route", async () => {
|
||||
@@ -1147,6 +1221,262 @@ describe("module route HEAD semantics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route body limits", () => {
|
||||
it("rejects a non-empty declared body on a route declaring maxBodyBytes: 0", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 0,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: "a",
|
||||
headers: { "content-length": "1", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-empty streamed body on a route declaring maxBodyBytes: 0", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 0,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("a"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a genuinely empty body on a route declaring maxBodyBytes: 0", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 0,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts a body exactly at an explicit maxBodyBytes limit and rejects one byte over", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const limit = 32;
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: limit,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const atLimit = "a".repeat(limit);
|
||||
const atLimitResponse = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: atLimit,
|
||||
headers: { "content-length": String(limit), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(atLimitResponse.status).toBe(200);
|
||||
|
||||
const overLimit = "a".repeat(limit + 1);
|
||||
const overLimitResponse = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: overLimit,
|
||||
headers: { "content-length": String(limit + 1), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(overLimitResponse.status).toBe(413);
|
||||
expect(await overLimitResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects an oversized streamed body on a route with an explicit limit, with no declared Content-Length", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const limit = 64;
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: limit,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const chunk = new Uint8Array(16).fill(97);
|
||||
let enqueued = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (enqueued > limit) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
enqueued += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enforces the body limit before authorization runs, so an oversized denied request never reaches the evaluator", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 8,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: "a".repeat(9),
|
||||
headers: { "content-length": "9", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never reads an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 8,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const chunk = new Uint8Array(16).fill(97);
|
||||
let pullCount = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pullCount += 1;
|
||||
if (pullCount > 3) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: stream,
|
||||
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("applies the route's own declared limit to a GET route too", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 0,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module routes and the rest of the API", () => {
|
||||
it("does not invoke a handler for an unmatched module path", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
@@ -1192,3 +1522,525 @@ describe("module routes and the rest of the API", () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 4: the decoded-parameter boundary and the routing ambiguity that made it dangerous.
|
||||
*
|
||||
* These drive the pinned Hono matcher with real requests rather than asserting what a mock returns,
|
||||
* because the whole point is what the real router actually hands a module handler.
|
||||
*/
|
||||
describe("module route parameters are decoded, untrusted input", () => {
|
||||
function paramsApp() {
|
||||
const seen: Array<Readonly<Record<string, string>>> = [];
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
|
||||
seen.push(context.params);
|
||||
return Response.json({ id: context.params.id });
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/items/:id",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
return { app, handler, seen };
|
||||
}
|
||||
|
||||
it.each([
|
||||
// A single encoded segment can carry characters the declaration grammar forbids outright.
|
||||
["an encoded path separator and dot segments", "%2F..%2Fadmin", "/../admin"],
|
||||
["an encoded backslash", "a%5Cb", "a\\b"],
|
||||
["an embedded NUL", "a%00b", "a\u0000b"],
|
||||
["encoded CR/LF", "a%0D%0Ab", "a\r\nb"],
|
||||
["a percent sign", "100%25", "100%"],
|
||||
["non-ASCII text", "%C3%A9v%C3%A9nement", "événement"],
|
||||
["a statement separator", "1%3B%20drop", "1; drop"],
|
||||
])("delivers %s decoded, exactly as the router produced it", async (_label, encoded, decoded) => {
|
||||
const { app, seen } = paramsApp();
|
||||
|
||||
const response = await app.request(`/api/v1/modules/calendar/items/${encoded}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ id: decoded });
|
||||
// The declared path grammar (`/items/:id`) permits none of these characters — validating the
|
||||
// *declaration* says nothing about what a request may send, which is exactly why a handler
|
||||
// must validate the decoded value against its own domain grammar before using it.
|
||||
expect(seen[0]?.id).toBe(decoded);
|
||||
expect(Object.isFrozen(seen[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps parameter delivery behind authorization", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("never"));
|
||||
const { app, evaluator } = buildModuleApp({
|
||||
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: false }) },
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/items/:id",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(evaluator.authorize).toHaveBeenCalled();
|
||||
// An unauthorized caller can never observe how the handler would treat a parameter, because
|
||||
// the handler never runs.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a handler reject a decoded value against its own domain grammar", async () => {
|
||||
// The platform supplies no generic parameter validator; this is the module's own check, which
|
||||
// is exactly the documented contract.
|
||||
const canonicalId = /^[0-9a-f]{8}$/u;
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
|
||||
const id = context.params.id;
|
||||
if (id === undefined || !canonicalId.test(id)) {
|
||||
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
|
||||
}
|
||||
return Response.json({ id });
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/items/:id",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect((await app.request("/api/v1/modules/calendar/items/deadbeef")).status).toBe(200);
|
||||
const rejected = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
|
||||
expect(rejected.status).toBe(400);
|
||||
expect(await rejected.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route overlap can never reach mounting", () => {
|
||||
it("would otherwise let registration order choose the governing permission", async () => {
|
||||
// Both orders are mounted directly here — bypassing the runtime rejection — to show precisely
|
||||
// what `MODULE_ROUTE_OVERLAP` prevents: the same request reaching a different handler *and* a
|
||||
// different permission depending only on the order the routes were registered in.
|
||||
const createPermission = parsePermissionId("calendar.events.create");
|
||||
const byId = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "byId" }));
|
||||
const create = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "new" }));
|
||||
const registry = permissionRegistry([
|
||||
{ id: readPermission, baseline: "public" },
|
||||
{ id: createPermission, baseline: "public" },
|
||||
]);
|
||||
const paramRoute = moduleRoute({
|
||||
handler: byId,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events/:id",
|
||||
permission: readPermission,
|
||||
});
|
||||
const staticRoute = moduleRoute({
|
||||
handler: create,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events/new",
|
||||
permission: createPermission,
|
||||
});
|
||||
|
||||
const parameterFirst = buildModuleApp({ registry, routes: [paramRoute, staticRoute] });
|
||||
const staticFirst = buildModuleApp({ registry, routes: [staticRoute, paramRoute] });
|
||||
|
||||
expect(
|
||||
await (await parameterFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
|
||||
).toEqual({ handler: "byId" });
|
||||
expect(
|
||||
await (await staticFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
|
||||
).toEqual({ handler: "new" });
|
||||
|
||||
// Core rejects this pair during staged registration, so no such snapshot can ever be published
|
||||
// to `mountModuleHttpRoutes` — see `core/tests/module-runtime.test.ts`.
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
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,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { 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";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||
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-${fixture.id}`,
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const defaultFixtures: readonly ModuleFixture[] = [
|
||||
{ id: "storage" },
|
||||
{ id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } },
|
||||
];
|
||||
|
||||
interface InstallOptions {
|
||||
readonly fixtures?: readonly ModuleFixture[];
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly platformOwner?: boolean;
|
||||
readonly bootDisabled?: readonly string[];
|
||||
readonly store?: ModuleLifecycleStore;
|
||||
/** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator
|
||||
* — never a bypass, so a delegated holder is exercised the same way a real one would be. */
|
||||
readonly grants?: readonly string[];
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly app: Hono;
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
readonly store: ModuleLifecycleStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
||||
* composition: resolve eligibility from the stored configuration, then build the UI publication
|
||||
* registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it
|
||||
* the same way here is what makes these assertions evidence about production composition rather than
|
||||
* about a bespoke test wiring.
|
||||
*/
|
||||
async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
for (const fixture of options.fixtures ?? defaultFixtures) {
|
||||
await createModule(modulesDirectory, fixture);
|
||||
}
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const store =
|
||||
options.store ??
|
||||
createInMemoryModuleLifecycleStore(
|
||||
(options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })),
|
||||
);
|
||||
const bootConfiguration = await store.readConfiguration();
|
||||
const eligibility = resolveModuleEligibility(modules, bootConfiguration);
|
||||
|
||||
const held = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
|
||||
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const authorizationStore = {
|
||||
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;
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
// Permission composition still runs over the whole discovered inventory, unchanged: a
|
||||
// disabled module's permissions stay registered and grantable, and nothing enforces them
|
||||
// because it mounts no routes.
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: {
|
||||
discoveredModules: modules,
|
||||
eligibility: eligibility.entries,
|
||||
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
||||
},
|
||||
store,
|
||||
},
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
return { app, modules, store };
|
||||
}
|
||||
|
||||
function owner(): InstallOptions {
|
||||
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
|
||||
}
|
||||
|
||||
interface LifecycleEntry {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
|
||||
readonly runtime: {
|
||||
readonly state: string;
|
||||
readonly reason?: string;
|
||||
readonly dependency?: { readonly kind: string; readonly moduleId: string };
|
||||
};
|
||||
readonly restartRequired: boolean;
|
||||
}
|
||||
|
||||
async function readLifecycle(app: Hono): Promise<readonly LifecycleEntry[]> {
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] };
|
||||
return body.modules;
|
||||
}
|
||||
|
||||
function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) {
|
||||
return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, {
|
||||
body: JSON.stringify({ enabled }),
|
||||
headers: { "content-type": "application/json", origin },
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/v1/module-lifecycle", () => {
|
||||
it("reports every discovered module with both axes separated", async () => {
|
||||
const { app } = await install(owner());
|
||||
|
||||
expect(await readLifecycle(app)).toEqual([
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "storage",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("explains a disabled module and the dependent it blocks, with a structured reason", async () => {
|
||||
const { app } = await install({ ...owner(), bootDisabled: ["storage"] });
|
||||
|
||||
expect(await readLifecycle(app)).toEqual([
|
||||
{
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "storage",
|
||||
restartRequired: false,
|
||||
runtime: { state: "disabled" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
id: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: {
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_disabled",
|
||||
state: "blocked",
|
||||
},
|
||||
version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("is not cached and varies on origin and cookie", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(response.headers.get("Vary")).toContain("Cookie");
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller", async () => {
|
||||
const { app } = await install();
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
});
|
||||
|
||||
it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => {
|
||||
// The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this
|
||||
// resource is deliberately restricted, because it is the only place a disabled module is
|
||||
// visible at all.
|
||||
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
const lifecycle = await app.request(MODULE_LIFECYCLE_PATH);
|
||||
|
||||
expect(inventory.status).toBe(200);
|
||||
expect(lifecycle.status).toBe(403);
|
||||
});
|
||||
|
||||
it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => {
|
||||
const { app } = await install({
|
||||
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
||||
// Holding it confers no module authority: the Dashboard's own manage permission is untouched by
|
||||
// this grant, so a lifecycle administrator cannot reorganize navigation.
|
||||
const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
expect(reorganize.status).toBe(403);
|
||||
});
|
||||
|
||||
it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => {
|
||||
const failing: ModuleLifecycleStore = {
|
||||
readConfiguration: async () => {
|
||||
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED");
|
||||
},
|
||||
setEnabled: async () => {
|
||||
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
|
||||
},
|
||||
};
|
||||
// Boot with a working store, then swap in the failing one for the request path only.
|
||||
const { app } = await install({ ...owner(), store: failingAfterBoot(failing) });
|
||||
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
/** Answers the one boot-time read with an empty configuration, then fails every later call — so a
|
||||
* request-path failure can be exercised without making startup composition itself impossible. */
|
||||
function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore {
|
||||
let bootRead = false;
|
||||
return {
|
||||
async readConfiguration() {
|
||||
if (!bootRead) {
|
||||
bootRead = true;
|
||||
return { modules: [] };
|
||||
}
|
||||
return failing.readConfiguration();
|
||||
},
|
||||
setEnabled: (input) => failing.setEnabled(input),
|
||||
};
|
||||
}
|
||||
|
||||
describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => {
|
||||
it("persists a decision and reports honestly that the running process has not changed", async () => {
|
||||
const { app } = await install(owner());
|
||||
|
||||
const response = await setEnabled(app, "announcements", false);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
configured: { enabled: false, explicit: true },
|
||||
id: "announcements",
|
||||
// Not `disabled`: this process is still serving that module. Claiming otherwise would be a
|
||||
// lie about a module that is still running.
|
||||
restartRequired: true,
|
||||
runtime: { state: "running" },
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the dependents of a newly disabled module as restart-pending too", async () => {
|
||||
const { app } = await install(owner());
|
||||
|
||||
await setEnabled(app, "storage", false);
|
||||
|
||||
expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([
|
||||
["storage", true],
|
||||
["announcements", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("is idempotent, and re-enabling clears the pending restart", async () => {
|
||||
const { app } = await install(owner());
|
||||
|
||||
await setEnabled(app, "announcements", false);
|
||||
await setEnabled(app, "announcements", false);
|
||||
const reEnabled = await setEnabled(app, "announcements", true);
|
||||
|
||||
expect(await reEnabled.json()).toEqual({
|
||||
configured: { enabled: true, explicit: true },
|
||||
id: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await setEnabled(app, "not-installed", false);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a malformed module identifier without reaching the store", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await setEnabled(app, "Not%20A%20Module", false);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
||||
const { app, store } = await install(owner());
|
||||
|
||||
const response = await setEnabled(app, "announcements", false, untrustedOrigin);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing origin", async () => {
|
||||
const { app } = await install(owner());
|
||||
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("refuses an authenticated caller without the restricted permission", async () => {
|
||||
const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||
|
||||
const response = await setEnabled(app, "announcements", false);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-boolean value", { enabled: "false" }],
|
||||
["a missing field", {}],
|
||||
["an unexpected key alongside a valid one", { enabled: false, force: true }],
|
||||
["an array", []],
|
||||
["a bare string", "disabled"],
|
||||
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
||||
const { app, store } = await install(owner());
|
||||
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
||||
const { app } = await install(owner());
|
||||
const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) });
|
||||
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||
body: oversized,
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("never leaks a filesystem path, package name, or manifest path through either verb", async () => {
|
||||
const { app } = await install(owner());
|
||||
const listed = await (
|
||||
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
||||
).text();
|
||||
const mutated = await (await setEnabled(app, "announcements", false)).text();
|
||||
|
||||
for (const payload of [listed, mutated]) {
|
||||
expect(payload).not.toContain("/tmp");
|
||||
expect(payload).not.toContain("@test/module-");
|
||||
expect(payload).not.toContain("ekklesia.module.json");
|
||||
expect(payload).not.toContain("dist/");
|
||||
expect(payload).not.toContain("entrypoint");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("lifecycle state governs module UI exposure", () => {
|
||||
it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => {
|
||||
const { app } = await install({
|
||||
bootDisabled: ["announcements"],
|
||||
platformOwner: true,
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as {
|
||||
readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[];
|
||||
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
|
||||
};
|
||||
|
||||
// Discovered, validated, and declaring a real compiled UI artifact — and still absent, because
|
||||
// publication is built from the eligible set rather than the discovered one.
|
||||
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]);
|
||||
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]);
|
||||
expect(navigation.groups).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => {
|
||||
const { app } = await install({
|
||||
bootDisabled: ["storage"],
|
||||
platformOwner: true,
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
|
||||
expect(inventory.modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not remove a module from navigation merely because a restart is pending", async () => {
|
||||
const { app } = await install({
|
||||
platformOwner: true,
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
|
||||
await setEnabled(app, "announcements", false);
|
||||
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
readonly modules: readonly { readonly id: string }[];
|
||||
};
|
||||
|
||||
// The module really is still mounted and still serving; hiding its UI now would break a viewer
|
||||
// mid-session for a change that has not taken effect. `restartRequired` is where that pending
|
||||
// change is reported.
|
||||
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lifecycle prefix is a sibling of the module route namespace", () => {
|
||||
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
|
||||
// `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named
|
||||
// `announcements` may legitimately register `PUT /enabled` — mounted at exactly
|
||||
// `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation
|
||||
// there, registration order would decide which one answers.
|
||||
expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
||||
expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle");
|
||||
|
||||
const { app } = await install(owner());
|
||||
const collision = await app.request("/api/v1/modules/announcements/enabled", {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(collision.status).toBe(404);
|
||||
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
@@ -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 ModuleRealtimePublisher,
|
||||
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 LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
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: 8,
|
||||
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(): LegacyModuleRuntimeServices {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,779 @@
|
||||
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,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
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,
|
||||
moduleUiSourceUrl,
|
||||
} 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,
|
||||
} 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";
|
||||
|
||||
/**
|
||||
* The whole chain, with nothing between the halves faked:
|
||||
*
|
||||
* ```text
|
||||
* module fixture on disk -> discovery -> module runtime started -> publication registry
|
||||
* -> inventory endpoint -> the shell's own bootstrap -> server-produced artifact URL
|
||||
* -> the generic loader -> mount -> the module calls its own API relatively
|
||||
* -> the shell resolves it to /api/v1/modules/<id>/... -> server permission enforcement
|
||||
* ```
|
||||
*
|
||||
* Every step runs real code. The module is discovered and *started* by Core's real runtime, its
|
||||
* routes are mounted by the real `createApiApp`, its artifact is published by the real ADR 0025
|
||||
* route, its descriptor comes from the real inventory endpoint, and the browser half is
|
||||
* `@ekklesia/shell`'s own exported protocol — the same functions the React shell calls — rather
|
||||
* than a reimplementation of them here.
|
||||
*
|
||||
* Two things are deliberately *not* faked-around and are worth naming. The shell's `fetch` is the
|
||||
* Hono app's own `request`, so requests really traverse routing, CORS, authorization middleware, and
|
||||
* handlers without needing a socket. And Node cannot `import()` an `http:` URL, so the artifact is
|
||||
* fetched over that same in-process HTTP path and the **bytes it returned** are handed to the
|
||||
* generic loader through a `data:` URL — a genuine module URL, the same separation ADR 0025 used.
|
||||
* No test here reads the artifact off the filesystem and calls that a proof of publication.
|
||||
*/
|
||||
const viewerId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A real, complete module: a manifest declaring a restricted permission and a UI entrypoint, a
|
||||
* server entrypoint that registers a permission-gated route, and a compiled UI artifact that reads
|
||||
* its host context and calls its own API relatively.
|
||||
*
|
||||
* The UI artifact is written as plain ESM rather than compiled from TSX here: ADR 0023's pipeline is
|
||||
* already proven end to end by `templates/module`'s own tests, and what this test needs is a byte
|
||||
* sequence whose journey through publication, inventory, and the loader can be asserted exactly.
|
||||
*/
|
||||
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"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${id}`,
|
||||
type: "module",
|
||||
version: "0.1.0",
|
||||
}),
|
||||
);
|
||||
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",
|
||||
ui: "./dist/ui/index.js",
|
||||
...(withDisplay
|
||||
? {
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: `${id}.display.view`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 8,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "Read example records.",
|
||||
id: `${id}.records.read`,
|
||||
},
|
||||
...(withDisplay
|
||||
? [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the example display.",
|
||||
id: `${id}.display.view`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
version: "0.1.0",
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/records",
|
||||
permission: ${JSON.stringify(`${id}.records.read`)},
|
||||
maxBodyBytes: 0,
|
||||
async handler() {
|
||||
return Response.json([{ id: "a" }, { id: "b" }]);
|
||||
},
|
||||
});
|
||||
},
|
||||
};\n`,
|
||||
);
|
||||
// The module UI: host-neutral, asks for a relative path, and renders from the host context. It
|
||||
// hardcodes no origin, no prefix, and no permission decision of its own.
|
||||
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) {
|
||||
if (!context.permissions.has(${JSON.stringify(`${id}.records.read`)})) {
|
||||
container.textContent = context.moduleId + ":denied";
|
||||
return () => { container.textContent = ""; };
|
||||
}
|
||||
const response = await context.api.request("records");
|
||||
const records = response.ok ? await response.json() : [];
|
||||
container.textContent =
|
||||
context.moduleId + ":" + response.status + ":" + records.length;
|
||||
return () => { container.textContent = ""; };
|
||||
},
|
||||
};\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
|
||||
* single module proves very little. */
|
||||
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
|
||||
readonly platformOwner?: boolean;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly moduleApiCalls: readonly string[];
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a real API over a real discovered-and-started module, and returns the `EkklesiaHostConfig`
|
||||
* a browser would use — with `fetch` wired to the app itself.
|
||||
*/
|
||||
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
|
||||
for (const extra of options.extraModules ?? []) {
|
||||
await createFixtureModule(modulesDirectory, extra.id, extra.name);
|
||||
}
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
});
|
||||
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
||||
// route snapshot below is the one it actually published.
|
||||
const moduleRuntime = await startDiscoveredModuleRuntime(modules, services);
|
||||
|
||||
const granted = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
||||
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const store = {
|
||||
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;
|
||||
|
||||
const moduleUi = createModuleUiPublicationRegistry(modules);
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleRealtime: moduleRuntime.realtime,
|
||||
moduleServices: services,
|
||||
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(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
});
|
||||
|
||||
const moduleApiCalls: string[] = [];
|
||||
const hostFetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname.startsWith("/api/v1/modules/")) {
|
||||
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
|
||||
}
|
||||
return app.request(url.href, init);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
return {
|
||||
config: { apiBaseUrl: "https://ekklesia.example.test", fetch: hostFetch },
|
||||
moduleApiCalls,
|
||||
stop: () => moduleRuntime.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Node cannot `import()` an `http:` URL, so the artifact is fetched over the real route and its
|
||||
* exact bytes become a `data:` URL. The loader still performs a genuine dynamic import of a real
|
||||
* module URL; only the transport differs, and the transport itself is asserted separately. */
|
||||
async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promise<string> {
|
||||
const response = await (harness.config.fetch as typeof fetch)(sourceUrl);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"];
|
||||
|
||||
describe("end to end: a discovered module's UI mounts in the Ekklesia host", () => {
|
||||
it("bootstraps, publishes, loads, mounts, and reaches the module's own API", async () => {
|
||||
const harness = await startHarness({
|
||||
grants: ["example.records.read"],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
// 1. The shell's own bootstrap: principal, then inventory.
|
||||
const bootstrap = await bootstrapHost(harness.config);
|
||||
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId });
|
||||
// A module nobody has arranged yet is ungrouped and visible — no initialization write.
|
||||
expect(bootstrap.navigation.groups).toEqual([]);
|
||||
expect(bootstrap.navigation.ungrouped).toHaveLength(1);
|
||||
expect(bootstrap.navigation.ungrouped[0]?.name).toBe("Example module");
|
||||
|
||||
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||
if (!descriptor) {
|
||||
throw new Error("the inventory returned no module");
|
||||
}
|
||||
// 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",
|
||||
});
|
||||
|
||||
// 3. The artifact really is served by the real publication route at that URL.
|
||||
const sourceUrl = moduleUiSourceUrl(harness.config, descriptor);
|
||||
expect(sourceUrl).toBe("https://ekklesia.example.test/api/v1/module-ui/example/0.1.0");
|
||||
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||
|
||||
// 4. The generic loader mounts it with the context the shell built.
|
||||
const container = containerSink();
|
||||
const mounted = await loadModuleUi({
|
||||
container: container as unknown as LoaderContainer,
|
||||
context: createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor,
|
||||
principal: bootstrap.principal,
|
||||
}),
|
||||
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
|
||||
});
|
||||
|
||||
// 5. The module rendered, having reached its own backend through the host.
|
||||
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(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();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
|
||||
// Signed in, but holding no grant for the module's restricted permission.
|
||||
const harness = await startHarness({
|
||||
grants: [],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
const bootstrap = await bootstrapHost(harness.config);
|
||||
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||
if (!descriptor) {
|
||||
throw new Error("the inventory returned no module");
|
||||
}
|
||||
|
||||
// The hint is honest about it...
|
||||
expect(descriptor.permissions).toEqual([]);
|
||||
const context = createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor,
|
||||
principal: bootstrap.principal,
|
||||
});
|
||||
expect(context.permissions.has("example.records.read")).toBe(false);
|
||||
|
||||
// ...and the hint is not what protects the route. Calling anyway — exactly what a buggy or
|
||||
// hostile UI would do — is still refused by the server.
|
||||
const response = await context.api.request("records");
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the module's own denied state from the hint, without calling the API", async () => {
|
||||
const harness = await startHarness({
|
||||
grants: [],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
const bootstrap = await bootstrapHost(harness.config);
|
||||
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||
if (!descriptor) {
|
||||
throw new Error("the inventory returned no module");
|
||||
}
|
||||
const artifactUrl = await publishedArtifactUrl(
|
||||
harness,
|
||||
moduleUiSourceUrl(harness.config, descriptor),
|
||||
);
|
||||
const container = containerSink();
|
||||
|
||||
await loadModuleUi({
|
||||
container: container as unknown as LoaderContainer,
|
||||
context: createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor,
|
||||
principal: bootstrap.principal,
|
||||
}),
|
||||
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
|
||||
});
|
||||
|
||||
// Hiding a control is a usability improvement: the module simply did not ask.
|
||||
expect(mountedText(container)).toBe("example:denied");
|
||||
expect(harness.moduleApiCalls).toEqual([]);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders a configured arrangement, and a move changes presentation only", async () => {
|
||||
const harness = await startHarness({
|
||||
extraModules: [
|
||||
{ id: "video", name: "Video" },
|
||||
{ id: "calendar", name: "Calendar" },
|
||||
],
|
||||
grants: ["example.records.read"],
|
||||
platformOwner: true,
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
const organize = (url: string, body: unknown) =>
|
||||
(harness.config.fetch as typeof fetch)(`https://ekklesia.example.test${url}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
try {
|
||||
// An administrator organizes the Dashboard, through the real mutation API.
|
||||
await organize("/api/v1/dashboard/navigation/groups/media", { label: "Media", order: 10 });
|
||||
await organize("/api/v1/dashboard/navigation/groups/planning", {
|
||||
label: "Planning",
|
||||
order: 20,
|
||||
});
|
||||
await organize("/api/v1/dashboard/navigation/placements/video", {
|
||||
groupId: "media",
|
||||
order: 10,
|
||||
});
|
||||
await organize("/api/v1/dashboard/navigation/placements/example", {
|
||||
groupId: "media",
|
||||
order: 20,
|
||||
});
|
||||
await organize("/api/v1/dashboard/navigation/placements/calendar", {
|
||||
groupId: "planning",
|
||||
order: 10,
|
||||
});
|
||||
|
||||
// The shell's own bootstrap sees exactly that arrangement.
|
||||
const before = await bootstrapHost(harness.config);
|
||||
expect(
|
||||
before.navigation.groups.map((group) => [
|
||||
group.label,
|
||||
group.modules.map((entry) => entry.name),
|
||||
]),
|
||||
).toEqual([
|
||||
["Media", ["Video", "Example module"]],
|
||||
["Planning", ["Calendar"]],
|
||||
]);
|
||||
expect(before.navigation.ungrouped).toEqual([]);
|
||||
|
||||
// Mounting from inside a group uses the descriptor the group carried, untouched.
|
||||
const groupedEntry = before.navigation.groups[0]?.modules[1];
|
||||
if (!groupedEntry) {
|
||||
throw new Error("the arrangement did not contain the expected module");
|
||||
}
|
||||
const container = containerSink();
|
||||
const mounted = await loadModuleUi({
|
||||
container: container as unknown as LoaderContainer,
|
||||
context: createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor: groupedEntry.descriptor,
|
||||
principal: before.principal,
|
||||
}),
|
||||
source: {
|
||||
moduleId: groupedEntry.descriptor.id,
|
||||
url: await publishedArtifactUrl(
|
||||
harness,
|
||||
moduleUiSourceUrl(harness.config, groupedEntry.descriptor),
|
||||
),
|
||||
version: groupedEntry.descriptor.version,
|
||||
},
|
||||
});
|
||||
expect(mountedText(container)).toBe("example:200:2");
|
||||
await mounted.unmount();
|
||||
|
||||
// Now move it somewhere else entirely.
|
||||
await organize("/api/v1/dashboard/navigation/groups/sunday-production", {
|
||||
label: "Sunday Production",
|
||||
order: 5,
|
||||
});
|
||||
await organize("/api/v1/dashboard/navigation/placements/example", {
|
||||
groupId: "sunday-production",
|
||||
order: 10,
|
||||
});
|
||||
|
||||
const after = await bootstrapHost(harness.config);
|
||||
expect(
|
||||
after.navigation.groups.map((group) => [
|
||||
group.label,
|
||||
group.modules.map((entry) => entry.name),
|
||||
]),
|
||||
).toEqual([
|
||||
["Sunday Production", ["Example module"]],
|
||||
["Media", ["Video"]],
|
||||
["Planning", ["Calendar"]],
|
||||
]);
|
||||
|
||||
const movedEntry = after.navigation.groups[0]?.modules[0];
|
||||
if (!movedEntry) {
|
||||
throw new Error("the moved module was not found in its new group");
|
||||
}
|
||||
// The whole point of the pass: the module is byte-identical across the move — same artifact
|
||||
// URL, same API root, same permissions, same version, same name.
|
||||
expect(movedEntry).toEqual(groupedEntry);
|
||||
|
||||
// And it still mounts the same artifact and reaches the same API.
|
||||
const callsBeforeRemount = harness.moduleApiCalls.length;
|
||||
const movedContainer = containerSink();
|
||||
const remounted = await loadModuleUi({
|
||||
container: movedContainer as unknown as LoaderContainer,
|
||||
context: createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor: movedEntry.descriptor,
|
||||
principal: after.principal,
|
||||
}),
|
||||
source: {
|
||||
moduleId: movedEntry.descriptor.id,
|
||||
url: await publishedArtifactUrl(
|
||||
harness,
|
||||
moduleUiSourceUrl(harness.config, movedEntry.descriptor),
|
||||
),
|
||||
version: movedEntry.descriptor.version,
|
||||
},
|
||||
});
|
||||
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||
"GET /api/v1/modules/example/records",
|
||||
]);
|
||||
await remounted.unmount();
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a newly installed module visible beside an existing arrangement", async () => {
|
||||
const harness = await startHarness({
|
||||
extraModules: [{ id: "video", name: "Video" }],
|
||||
platformOwner: true,
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
await (harness.config.fetch as typeof fetch)(
|
||||
"https://ekklesia.example.test/api/v1/dashboard/navigation/groups/media",
|
||||
{
|
||||
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: "https://dashboard.example.test",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
await (harness.config.fetch as typeof fetch)(
|
||||
"https://ekklesia.example.test/api/v1/dashboard/navigation/placements/video",
|
||||
{
|
||||
body: JSON.stringify({ groupId: "media", order: 10 }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: "https://dashboard.example.test",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
|
||||
const bootstrap = await bootstrapHost(harness.config);
|
||||
|
||||
expect(bootstrap.navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
|
||||
// `example` was never placed, and is still reachable rather than invisible.
|
||||
expect(bootstrap.navigation.ungrouped.map((entry) => entry.name)).toEqual(["Example module"]);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
|
||||
const harness = await startHarness();
|
||||
try {
|
||||
const bootstrap = await bootstrapHost(harness.config);
|
||||
|
||||
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
|
||||
expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] });
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a module UI that tries to address another module's API through the host", async () => {
|
||||
const harness = await startHarness({
|
||||
grants: ["example.records.read"],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
const bootstrap = await bootstrapHost(harness.config);
|
||||
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||
if (!descriptor) {
|
||||
throw new Error("the inventory returned no module");
|
||||
}
|
||||
const context = createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor,
|
||||
principal: bootstrap.principal,
|
||||
});
|
||||
|
||||
await expect(context.api.request("../../grants?userId=someone")).rejects.toMatchObject({
|
||||
code: "MODULE_API_PATH_INVALID",
|
||||
});
|
||||
// It never became a request at all.
|
||||
expect(harness.moduleApiCalls).toEqual([]);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
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,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
createAuthorizationEvaluator,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { 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 {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} 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";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly version?: string;
|
||||
/** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */
|
||||
readonly withUi?: boolean;
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
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> {
|
||||
const version = fixture.version ?? "0.1.0";
|
||||
const moduleDirectory = path.join(modulesDirectory, fixture.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-${fixture.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.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: 8,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
description: `Test permission for ${fixture.id}.`,
|
||||
})),
|
||||
version,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
|
||||
);
|
||||
if (fixture.withUi) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`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 {
|
||||
readonly fixtures: readonly ModuleFixture[];
|
||||
/** Anonymous by default, so every scenario has to opt in to being signed in. */
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
/** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so
|
||||
* baselines, the owner bypass, and deny-by-default all behave exactly as in production. */
|
||||
readonly grants?: readonly string[];
|
||||
readonly platformOwner?: boolean;
|
||||
/** Makes the grant port fail, so the "a hint that could not be determined is never reported as
|
||||
* denied" path can be exercised. */
|
||||
readonly grantsUnavailable?: boolean;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly app: Hono;
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
readonly modulesDirectory: string;
|
||||
readonly permissionRegistry: PermissionRegistry;
|
||||
}
|
||||
|
||||
/** A real installation: fixtures on disk, Core's real discovery, real permission composition, the
|
||||
* real publication registry, the real authorization evaluator, and the real `createApiApp`. Only
|
||||
* identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL
|
||||
* to answer the questions these tests ask. */
|
||||
async function install(options: InstallOptions): Promise<Installation> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
for (const fixture of options.fixtures) {
|
||||
await createModule(modulesDirectory, fixture);
|
||||
}
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const granted = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: {
|
||||
hasActiveGrant: async (query) => {
|
||||
if (options.grantsUnavailable) {
|
||||
throw new Error("grant store unavailable");
|
||||
}
|
||||
return granted.has(query.permission);
|
||||
},
|
||||
},
|
||||
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||
});
|
||||
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const store = {
|
||||
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;
|
||||
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
});
|
||||
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: permissionRegistry, store },
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: services,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
return { app, modules, modulesDirectory, permissionRegistry };
|
||||
}
|
||||
|
||||
function signedIn(): EkklesiaPrincipal {
|
||||
return createAuthenticatedPrincipal(viewerId);
|
||||
}
|
||||
|
||||
async function inventoryOf(app: Hono): Promise<{
|
||||
readonly body: string;
|
||||
readonly modules: ReadonlyArray<Record<string, unknown>>;
|
||||
readonly response: Response;
|
||||
}> {
|
||||
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
const body = await response.clone().text();
|
||||
const parsed = (await response.json()) as { modules?: ReadonlyArray<Record<string, unknown>> };
|
||||
return { body, modules: parsed.modules ?? [], response };
|
||||
}
|
||||
|
||||
describe("module UI inventory — what it lists", () => {
|
||||
it("describes a publishable module with exactly the fields a host needs", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", permissions: [], withUi: true }],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules, response } = await inventoryOf(app);
|
||||
|
||||
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 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(),
|
||||
});
|
||||
const module = discovered[0] as DiscoveredModule;
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
// Not "looks like the right shape" — byte-identical to what the helpers produce, so a second
|
||||
// hand-written template string anywhere would fail here.
|
||||
expect(modules[0]?.ui).toEqual({
|
||||
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 () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "calendar" }, { id: "example", withUi: true }],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
expect(modules.map((module) => module.id)).toEqual(["example"]);
|
||||
});
|
||||
|
||||
it("is empty, not an error, when no installed module provides a UI", async () => {
|
||||
const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() });
|
||||
|
||||
const { modules, response } = await inventoryOf(app);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders modules deterministically, matching the publication registry", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{ id: "signage", withUi: true },
|
||||
{ id: "announcements", withUi: true },
|
||||
{ id: "calendar", withUi: true },
|
||||
],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const first = await inventoryOf(app);
|
||||
const second = await inventoryOf(app);
|
||||
|
||||
// Discovery order, which is sorted; the same on every request.
|
||||
expect(first.modules.map((module) => module.id)).toEqual([
|
||||
"announcements",
|
||||
"calendar",
|
||||
"signage",
|
||||
]);
|
||||
expect(second.modules.map((module) => module.id)).toEqual(
|
||||
first.modules.map((module) => module.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaks no filesystem path, package root, or other manifest internal", async () => {
|
||||
const {
|
||||
app,
|
||||
modules: discovered,
|
||||
modulesDirectory,
|
||||
} = await install({
|
||||
fixtures: [{ id: "example", permissions: [], withUi: true }],
|
||||
principal: signedIn(),
|
||||
});
|
||||
const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string;
|
||||
|
||||
const { body, modules } = await inventoryOf(app);
|
||||
|
||||
expect(body).not.toContain(modulesDirectory);
|
||||
expect(body).not.toContain(artifactPath);
|
||||
expect(body).not.toContain(tmpdir());
|
||||
expect(body).not.toContain("dist");
|
||||
expect(body).not.toContain("@test/module-example");
|
||||
expect(body).not.toContain("ekklesia.module.json");
|
||||
// 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",
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries no navigation or presentation metadata", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", withUi: true }],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { body } = await inventoryOf(app);
|
||||
|
||||
// ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module
|
||||
// declaration. The manifest's own `name` and `description` must not leak into it by accident.
|
||||
for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) {
|
||||
expect(body, field).not.toContain(`"${field}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module UI inventory — authentication", () => {
|
||||
it("refuses an anonymous caller", async () => {
|
||||
const { app } = await install({ fixtures: [{ id: "example", withUi: true }] });
|
||||
|
||||
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
});
|
||||
|
||||
it("tells an anonymous caller nothing about what is installed", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{ id: "example", withUi: true },
|
||||
{ id: "calendar", withUi: true },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
|
||||
|
||||
expect(body).not.toContain("example");
|
||||
expect(body).not.toContain("calendar");
|
||||
});
|
||||
|
||||
it("admits any signed-in user without needing an explicit grant", async () => {
|
||||
// `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell
|
||||
// that rendered nothing until an operator granted every user a permission would be unusable.
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", withUi: true }],
|
||||
grants: [],
|
||||
principal: signedIn(),
|
||||
platformOwner: false,
|
||||
});
|
||||
|
||||
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("is never cached and varies on the caller's identity", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", withUi: true }],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const response = await app.request(MODULE_UI_INVENTORY_PATH, {
|
||||
headers: { origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
const vary = response.headers.get("vary")?.toLowerCase() ?? "";
|
||||
expect(vary).toContain("origin");
|
||||
expect(vary).toContain("cookie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module UI inventory — permission hints", () => {
|
||||
const readPermission = { baseline: "restricted" as const, id: "example.records.read" };
|
||||
const writePermission = { baseline: "restricted" as const, id: "example.records.write" };
|
||||
|
||||
it("returns only the permissions the server actually granted this principal", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||
grants: ["example.records.read"],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
expect(modules[0]?.permissions).toEqual(["example.records.read"]);
|
||||
});
|
||||
|
||||
it("returns none of them for a signed-in user holding no grant", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||
grants: [],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
// Never a default of true, and never the module's full declared surface.
|
||||
expect(modules[0]?.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it("honours a baseline that needs no grant at all", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{
|
||||
id: "example",
|
||||
permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission],
|
||||
withUi: true,
|
||||
},
|
||||
],
|
||||
grants: [],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
expect(modules[0]?.permissions).toEqual(["example.records.list"]);
|
||||
});
|
||||
|
||||
it("honours the platform-owner bypass, exactly like a real request would", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||
grants: [],
|
||||
platformOwner: true,
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]);
|
||||
});
|
||||
|
||||
it("scopes hints to each module's own declared permissions", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{ id: "example", permissions: [readPermission], withUi: true },
|
||||
{
|
||||
id: "calendar",
|
||||
permissions: [{ baseline: "restricted", id: "calendar.events.read" }],
|
||||
withUi: true,
|
||||
},
|
||||
],
|
||||
grants: ["example.records.read", "calendar.events.read"],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
const byId = new Map(modules.map((module) => [module.id, module.permissions]));
|
||||
// Neither module learns anything about the other's authorization state, and neither is told
|
||||
// about Core's own permissions.
|
||||
expect(byId.get("example")).toEqual(["example.records.read"]);
|
||||
expect(byId.get("calendar")).toEqual(["calendar.events.read"]);
|
||||
expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage");
|
||||
expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read");
|
||||
});
|
||||
|
||||
it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", permissions: [readPermission], withUi: true }],
|
||||
grantsUnavailable: true,
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
|
||||
// A hint that silently read "denied" during an outage would blank the UI and look like a
|
||||
// permissions problem.
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("module UI inventory — it is not a module management API", () => {
|
||||
it("routes no method other than GET", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", withUi: true }],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
const response = await app.request(MODULE_UI_INVENTORY_PATH, { method });
|
||||
expect(response.status, method).toBe(404);
|
||||
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
|
||||
}
|
||||
});
|
||||
|
||||
it("still refuses to serve an artifact under the collection path", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [{ id: "example", withUi: true }],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) {
|
||||
const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`);
|
||||
expect(response.status, suffix).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,86 @@
|
||||
import type {
|
||||
DashboardConfiguration,
|
||||
DashboardGroupRecord,
|
||||
DashboardPlacementRecord,
|
||||
DashboardStore,
|
||||
SetDashboardPlacementInput,
|
||||
UpsertDashboardGroupInput,
|
||||
} from "@ekklesia/core/dashboard";
|
||||
import { DashboardStoreError } from "@ekklesia/core/dashboard";
|
||||
|
||||
/**
|
||||
* An in-memory `DashboardStore` for tests that exercise **routing and resolution**, not persistence.
|
||||
*
|
||||
* It reproduces only the two behaviours those tests depend on — a placement may not name a group
|
||||
* that does not exist, and deleting a group ungroups its modules (the schema's
|
||||
* `on delete set null`) — and deliberately reproduces nothing else. Everything the real store owns
|
||||
* that this double would only be guessing at (identifier grammar, label trimming and bounds,
|
||||
* ordering bounds, transactional behaviour, and the actual `on delete set null` constraint) is
|
||||
* proven against real PostgreSQL in `core/tests/dashboard-store.integration.ts` instead, so this
|
||||
* double can never quietly stand in for a persistence guarantee nobody checked.
|
||||
*/
|
||||
export function createInMemoryDashboardStore(
|
||||
seed: Partial<DashboardConfiguration> = {},
|
||||
): DashboardStore {
|
||||
const groups = new Map<string, DashboardGroupRecord>(
|
||||
(seed.groups ?? []).map((group) => [group.id, { ...group }] as const),
|
||||
);
|
||||
const placements = new Map<string, DashboardPlacementRecord>(
|
||||
(seed.placements ?? []).map((placement) => [placement.moduleId, { ...placement }] as const),
|
||||
);
|
||||
|
||||
const sorted = <Record extends { readonly sortOrder: number }>(
|
||||
records: readonly Record[],
|
||||
key: (record: Record) => string,
|
||||
): readonly Record[] =>
|
||||
Object.freeze(
|
||||
[...records].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
(key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0),
|
||||
),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
async deleteGroup(groupId: string): Promise<void> {
|
||||
if (!groups.delete(groupId)) {
|
||||
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||
}
|
||||
for (const [moduleId, placement] of placements) {
|
||||
if (placement.groupId === groupId) {
|
||||
placements.set(moduleId, { ...placement, groupId: null });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async readConfiguration(): Promise<DashboardConfiguration> {
|
||||
return Object.freeze({
|
||||
groups: sorted([...groups.values()], (group) => group.id),
|
||||
placements: sorted([...placements.values()], (placement) => placement.moduleId),
|
||||
});
|
||||
},
|
||||
|
||||
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
|
||||
if (input.groupId !== null && !groups.has(input.groupId)) {
|
||||
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||
}
|
||||
const record: DashboardPlacementRecord = Object.freeze({
|
||||
groupId: input.groupId,
|
||||
moduleId: input.moduleId,
|
||||
sortOrder: input.sortOrder,
|
||||
});
|
||||
placements.set(input.moduleId, record);
|
||||
return record;
|
||||
},
|
||||
|
||||
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
|
||||
const record: DashboardGroupRecord = Object.freeze({
|
||||
id: input.id,
|
||||
label: input.label.trim(),
|
||||
sortOrder: input.sortOrder,
|
||||
});
|
||||
groups.set(input.id, record);
|
||||
return record;
|
||||
},
|
||||
});
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
import type { ModuleEnablementRecord } from "@ekklesia/contracts";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleLifecycleConfiguration,
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleLifecycleStore,
|
||||
SetModuleEnabledInput,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { ModuleLifecycleStoreError, resolveModuleEligibility } from "@ekklesia/core/modules";
|
||||
|
||||
/**
|
||||
* An in-memory `ModuleLifecycleStore` for tests that exercise **routing and resolution**, not
|
||||
* persistence.
|
||||
*
|
||||
* It reproduces only what those tests depend on — records survive a write and read back in
|
||||
* module-id order, and an invalid identifier is rejected — and deliberately reproduces nothing
|
||||
* else. The real table's constraints, the upsert's conflict target, and the fact that a record for
|
||||
* an undeployed module is retained are proven against real PostgreSQL in
|
||||
* `core/tests/module-lifecycle-store.integration.ts`, so this double can never quietly stand in for
|
||||
* a persistence guarantee nobody checked.
|
||||
*/
|
||||
export function createInMemoryModuleLifecycleStore(
|
||||
seed: readonly ModuleEnablementRecord[] = [],
|
||||
): ModuleLifecycleStore {
|
||||
const records = new Map<string, boolean>(
|
||||
seed.map((record) => [record.moduleId, record.enabled] as const),
|
||||
);
|
||||
return Object.freeze({
|
||||
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
|
||||
return Object.freeze({
|
||||
modules: Object.freeze(
|
||||
[...records.entries()]
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })),
|
||||
),
|
||||
});
|
||||
},
|
||||
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
|
||||
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) {
|
||||
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
|
||||
}
|
||||
records.set(input.moduleId, input.enabled);
|
||||
return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered
|
||||
* inventory and the stored configuration, then record what would have started — rather than
|
||||
* hand-writing one, so a test can never assert against a snapshot the real composition would never
|
||||
* produce.
|
||||
*/
|
||||
export function moduleLifecycleSnapshotFor(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
configuration: ModuleLifecycleConfiguration = { modules: [] },
|
||||
): ModuleLifecycleSnapshot {
|
||||
const resolution = resolveModuleEligibility(discoveredModules, configuration);
|
||||
return Object.freeze({
|
||||
discoveredModules,
|
||||
eligibility: resolution.entries,
|
||||
startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)),
|
||||
});
|
||||
}
|
||||
|
||||
/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit
|
||||
* "no modules" statement the required option asks for, never an implicit default. */
|
||||
export function emptyModuleLifecycle(): {
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly store: ModuleLifecycleStore;
|
||||
} {
|
||||
return {
|
||||
snapshot: moduleLifecycleSnapshotFor([]),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
type ModuleStorage,
|
||||
type ModuleStorageBody,
|
||||
ModuleStorageError,
|
||||
type ModuleStorageListing,
|
||||
type ModuleStorageListOptions,
|
||||
type ModuleStorageObject,
|
||||
type ModuleStorageObjectMetadata,
|
||||
type ModuleStoragePutOptions,
|
||||
type ModuleStorageReadUrl,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { DiscoveredModule, ModuleStorageLookup } from "@ekklesia/core/modules";
|
||||
import { createModuleStorageLookup, type StorageProvider } from "@ekklesia/core/storage";
|
||||
|
||||
/** Builds and drains Web streams without depending on DOM-only helper types, which this package's
|
||||
* TypeScript configuration deliberately does not load. */
|
||||
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
interface StoredObject {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly contentType?: string;
|
||||
readonly lastModified: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory `StorageProvider` for tests that exercise **wiring, scoping, and routing** rather
|
||||
* than the S3 protocol.
|
||||
*
|
||||
* It implements the same interface the real S3 provider does, so everything above the provider seam
|
||||
* — binding resolution, the scoped facade, lifecycle eligibility, route wiring — is exercised
|
||||
* unchanged. What it deliberately does *not* reproduce is the protocol itself: real SeaweedFS
|
||||
* semantics (non-idempotent `CreateBucket`, `NoSuchKey` vs `NoSuchBucket`, ETag quoting, streaming
|
||||
* bodies) are proven against a live server in `core/tests/module-storage.integration.ts`, so this
|
||||
* double can never quietly stand in for a provider guarantee nobody checked.
|
||||
*
|
||||
* It keys everything by **physical** bucket, exactly as the real provider does, which is what lets a
|
||||
* test assert that two modules declaring the same logical name really did land in different
|
||||
* namespaces.
|
||||
*/
|
||||
export function createInMemoryStorageProvider(): StorageProvider & {
|
||||
readonly buckets: ReadonlyMap<string, Map<string, StoredObject>>;
|
||||
ensureBucketCalls: number;
|
||||
} {
|
||||
const buckets = new Map<string, Map<string, StoredObject>>();
|
||||
const state = { ensureBucketCalls: 0 };
|
||||
|
||||
const objectsIn = (physicalBucket: string): Map<string, StoredObject> => {
|
||||
const bucket = buckets.get(physicalBucket);
|
||||
if (!bucket) {
|
||||
throw new ModuleStorageError("STORAGE_BUCKET_UNAVAILABLE");
|
||||
}
|
||||
return bucket;
|
||||
};
|
||||
|
||||
const metadataFor = (key: string, stored: StoredObject): ModuleStorageObjectMetadata =>
|
||||
Object.freeze({
|
||||
contentLength: stored.bytes.byteLength,
|
||||
key,
|
||||
lastModified: stored.lastModified,
|
||||
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
|
||||
});
|
||||
|
||||
const provider: StorageProvider = {
|
||||
async close(): Promise<void> {
|
||||
// Nothing to release.
|
||||
},
|
||||
async createReadUrl(
|
||||
physicalBucket: string,
|
||||
key: string,
|
||||
urlOptions: { readonly expiresInSeconds: number; readonly downloadFilename?: string },
|
||||
): Promise<ModuleStorageReadUrl> {
|
||||
// Keyed by physical bucket like everything else here, so a route test can prove the URL a
|
||||
// handler received belongs to that module's namespace and not another's.
|
||||
return Object.freeze({
|
||||
expiresAt: new Date(Date.now() + urlOptions.expiresInSeconds * 1000),
|
||||
url: `https://delivery.test/${physicalBucket}/${encodeURIComponent(key)}?token=fake`,
|
||||
});
|
||||
},
|
||||
async delete(physicalBucket: string, key: string): Promise<void> {
|
||||
objectsIn(physicalBucket).delete(key);
|
||||
},
|
||||
async ensureBucket(physicalBucket: string): Promise<void> {
|
||||
state.ensureBucketCalls += 1;
|
||||
if (!buckets.has(physicalBucket)) {
|
||||
buckets.set(physicalBucket, new Map());
|
||||
}
|
||||
},
|
||||
async get(physicalBucket: string, key: string): Promise<ModuleStorageObject> {
|
||||
const stored = objectsIn(physicalBucket).get(key);
|
||||
if (!stored) {
|
||||
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
|
||||
}
|
||||
return Object.freeze({
|
||||
...metadataFor(key, stored),
|
||||
body: streamOf(stored.bytes),
|
||||
});
|
||||
},
|
||||
async head(physicalBucket: string, key: string): Promise<ModuleStorageObjectMetadata> {
|
||||
const stored = objectsIn(physicalBucket).get(key);
|
||||
if (!stored) {
|
||||
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
|
||||
}
|
||||
return metadataFor(key, stored);
|
||||
},
|
||||
async list(
|
||||
physicalBucket: string,
|
||||
options: ModuleStorageListOptions,
|
||||
): Promise<ModuleStorageListing> {
|
||||
const entries = [...objectsIn(physicalBucket).entries()]
|
||||
.filter(([key]) => options.prefix === undefined || key.startsWith(options.prefix))
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
return Object.freeze({
|
||||
objects: Object.freeze(entries.map(([key, stored]) => metadataFor(key, stored))),
|
||||
});
|
||||
},
|
||||
async probe(): Promise<void> {
|
||||
// Always reachable.
|
||||
},
|
||||
async put(
|
||||
physicalBucket: string,
|
||||
key: string,
|
||||
body: ModuleStorageBody,
|
||||
options: ModuleStoragePutOptions,
|
||||
): Promise<ModuleStorageObjectMetadata> {
|
||||
const bytes =
|
||||
typeof body === "string"
|
||||
? new TextEncoder().encode(body)
|
||||
: body instanceof Uint8Array
|
||||
? body
|
||||
: await collect(body as ReadableStream<Uint8Array>);
|
||||
const stored: StoredObject = {
|
||||
bytes,
|
||||
lastModified: new Date(0),
|
||||
...(options.contentType === undefined ? {} : { contentType: options.contentType }),
|
||||
};
|
||||
objectsIn(physicalBucket).set(key, stored);
|
||||
return metadataFor(key, stored);
|
||||
},
|
||||
};
|
||||
|
||||
return Object.assign(provider, {
|
||||
buckets: buckets as ReadonlyMap<string, Map<string, StoredObject>>,
|
||||
get ensureBucketCalls(): number {
|
||||
return state.ensureBucketCalls;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface ModuleStorageOption {
|
||||
readonly snapshots: ModuleStorageLookup;
|
||||
readonly providerConfigured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `createApiApp` storage option the same way `startApi` does — resolve every module's
|
||||
* declared buckets against one provider and one prefix — rather than hand-writing a map, so a test
|
||||
* can never assert against a shape the real composition would not produce.
|
||||
*/
|
||||
export function moduleStorageOptionFor(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
provider?: StorageProvider,
|
||||
): ModuleStorageOption {
|
||||
return {
|
||||
providerConfigured: provider !== undefined,
|
||||
snapshots: createModuleStorageLookup(
|
||||
provider,
|
||||
"ekklesia",
|
||||
discoveredModules.map((module) => ({
|
||||
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
|
||||
moduleId: module.manifest.id,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The storage option for a `createApiApp` caller that exercises no storage at all — the explicit
|
||||
* "this installation has no object storage" statement the required option asks for. */
|
||||
export function emptyModuleStorage(): ModuleStorageOption {
|
||||
return { providerConfigured: false, snapshots: new Map<string, ModuleStorage>() };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,14 @@ import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"],
|
||||
schema: [
|
||||
"./src/identity/schema.ts",
|
||||
"./src/authorization/schema.ts",
|
||||
"./src/dashboard/schema.ts",
|
||||
"./src/modules/lifecycle-schema.ts",
|
||||
"./src/modules/configuration-schema.ts",
|
||||
"./src/devices/schema.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
prefix: "index",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "ekklesia_core_platform_owner_bootstrap" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"consumed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"consumed_for_user_id" text,
|
||||
CONSTRAINT "ekklesia_core_platform_owner_bootstrap_singleton_check" CHECK ("ekklesia_core_platform_owner_bootstrap"."id" = 'singleton')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_authorization_audit" DROP CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "ekklesia_core_platform_owner_bootstrap" ("id")
|
||||
SELECT 'singleton'
|
||||
WHERE EXISTS (SELECT 1 FROM "ekklesia_core_platform_owner")
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "ekklesia_core_dashboard_group" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"label" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_dashboard_group_id_check" CHECK (length("ekklesia_core_dashboard_group"."id") > 0),
|
||||
CONSTRAINT "ekklesia_core_dashboard_group_label_check" CHECK (length(btrim("ekklesia_core_dashboard_group"."label")) > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ekklesia_core_dashboard_placement" (
|
||||
"module_id" text PRIMARY KEY NOT NULL,
|
||||
"group_id" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_dashboard_placement_module_check" CHECK (length("ekklesia_core_dashboard_placement"."module_id") > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_dashboard_placement" ADD CONSTRAINT "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."ekklesia_core_dashboard_group"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_dashboard_group_order_idx" ON "ekklesia_core_dashboard_group" USING btree ("sort_order","id");--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_dashboard_placement_group_idx" ON "ekklesia_core_dashboard_placement" USING btree ("group_id","sort_order");
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "ekklesia_core_module_state" (
|
||||
"module_id" text PRIMARY KEY NOT NULL,
|
||||
"enabled" boolean NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_module_state_module_check" CHECK (length("ekklesia_core_module_state"."module_id") > 0)
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "ekklesia_core_module_configuration" (
|
||||
"module_id" text NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_module_configuration_module_id_key_pk" PRIMARY KEY("module_id","key"),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_module_check" CHECK (length("ekklesia_core_module_configuration"."module_id") > 0),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_key_check" CHECK (length("ekklesia_core_module_configuration"."key") > 0),
|
||||
CONSTRAINT "ekklesia_core_module_configuration_value_check" CHECK (length("ekklesia_core_module_configuration"."value") <= 2048)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_module_configuration_module_idx" ON "ekklesia_core_module_configuration" USING btree ("module_id");
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,710 @@
|
||||
{
|
||||
"id": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
|
||||
"prevId": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ekklesia_core_account": {
|
||||
"name": "ekklesia_core_account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_account_user_id_idx": {
|
||||
"name": "ekklesia_core_account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_account",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_session": {
|
||||
"name": "ekklesia_core_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_session_user_id_idx": {
|
||||
"name": "ekklesia_core_session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_session",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_session_token_unique": {
|
||||
"name": "ekklesia_core_session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_user": {
|
||||
"name": "ekklesia_core_user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_user_email_unique": {
|
||||
"name": "ekklesia_core_user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_verification": {
|
||||
"name": "ekklesia_core_verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_verification_identifier_idx": {
|
||||
"name": "ekklesia_core_verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_authorization_audit": {
|
||||
"name": "ekklesia_core_authorization_audit",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_user_id": {
|
||||
"name": "subject_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_authorization_audit_subject_idx": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "subject_user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_authorization_audit_action_check": {
|
||||
"name": "ekklesia_core_authorization_audit_action_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_scope_check": {
|
||||
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_grant": {
|
||||
"name": "ekklesia_core_grant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_kind": {
|
||||
"name": "source_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_ref": {
|
||||
"name": "source_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_by_actor_type": {
|
||||
"name": "created_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_actor_id": {
|
||||
"name": "created_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_grant_user_permission_idx": {
|
||||
"name": "ekklesia_core_grant_user_permission_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "permission_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_grant",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_grant_source_kind_check": {
|
||||
"name": "ekklesia_core_grant_source_kind_check",
|
||||
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||
},
|
||||
"ekklesia_core_grant_created_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner": {
|
||||
"name": "ekklesia_core_platform_owner",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_at": {
|
||||
"name": "granted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"granted_by_actor_type": {
|
||||
"name": "granted_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_by_actor_id": {
|
||||
"name": "granted_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_platform_owner",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"consumed_for_user_id": {
|
||||
"name": "consumed_for_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
{
|
||||
"id": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
|
||||
"prevId": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ekklesia_core_account": {
|
||||
"name": "ekklesia_core_account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_account_user_id_idx": {
|
||||
"name": "ekklesia_core_account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_account",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_session": {
|
||||
"name": "ekklesia_core_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_session_user_id_idx": {
|
||||
"name": "ekklesia_core_session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_session",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_session_token_unique": {
|
||||
"name": "ekklesia_core_session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_user": {
|
||||
"name": "ekklesia_core_user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_user_email_unique": {
|
||||
"name": "ekklesia_core_user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_verification": {
|
||||
"name": "ekklesia_core_verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_verification_identifier_idx": {
|
||||
"name": "ekklesia_core_verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_authorization_audit": {
|
||||
"name": "ekklesia_core_authorization_audit",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_user_id": {
|
||||
"name": "subject_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_authorization_audit_subject_idx": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "subject_user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_authorization_audit_action_check": {
|
||||
"name": "ekklesia_core_authorization_audit_action_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_scope_check": {
|
||||
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_grant": {
|
||||
"name": "ekklesia_core_grant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_kind": {
|
||||
"name": "source_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_ref": {
|
||||
"name": "source_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_by_actor_type": {
|
||||
"name": "created_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_actor_id": {
|
||||
"name": "created_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_grant_user_permission_idx": {
|
||||
"name": "ekklesia_core_grant_user_permission_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "permission_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_grant",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_grant_source_kind_check": {
|
||||
"name": "ekklesia_core_grant_source_kind_check",
|
||||
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||
},
|
||||
"ekklesia_core_grant_created_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner": {
|
||||
"name": "ekklesia_core_platform_owner",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_at": {
|
||||
"name": "granted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"granted_by_actor_type": {
|
||||
"name": "granted_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_by_actor_id": {
|
||||
"name": "granted_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_platform_owner",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"consumed_for_user_id": {
|
||||
"name": "consumed_for_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_group": {
|
||||
"name": "ekklesia_core_dashboard_group",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_group_order_idx": {
|
||||
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_group_id_check": {
|
||||
"name": "ekklesia_core_dashboard_group_id_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||
},
|
||||
"ekklesia_core_dashboard_group_label_check": {
|
||||
"name": "ekklesia_core_dashboard_group_label_check",
|
||||
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_placement": {
|
||||
"name": "ekklesia_core_dashboard_placement",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"group_id": {
|
||||
"name": "group_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_placement_group_idx": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "group_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||
"tableTo": "ekklesia_core_dashboard_group",
|
||||
"columnsFrom": ["group_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_placement_module_check": {
|
||||
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
{
|
||||
"id": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
|
||||
"prevId": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ekklesia_core_account": {
|
||||
"name": "ekklesia_core_account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_account_user_id_idx": {
|
||||
"name": "ekklesia_core_account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_account",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_session": {
|
||||
"name": "ekklesia_core_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_session_user_id_idx": {
|
||||
"name": "ekklesia_core_session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_session",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_session_token_unique": {
|
||||
"name": "ekklesia_core_session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_user": {
|
||||
"name": "ekklesia_core_user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_user_email_unique": {
|
||||
"name": "ekklesia_core_user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_verification": {
|
||||
"name": "ekklesia_core_verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_verification_identifier_idx": {
|
||||
"name": "ekklesia_core_verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_authorization_audit": {
|
||||
"name": "ekklesia_core_authorization_audit",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_user_id": {
|
||||
"name": "subject_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_authorization_audit_subject_idx": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "subject_user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_authorization_audit_action_check": {
|
||||
"name": "ekklesia_core_authorization_audit_action_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_scope_check": {
|
||||
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_grant": {
|
||||
"name": "ekklesia_core_grant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_kind": {
|
||||
"name": "source_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_ref": {
|
||||
"name": "source_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_by_actor_type": {
|
||||
"name": "created_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_actor_id": {
|
||||
"name": "created_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_grant_user_permission_idx": {
|
||||
"name": "ekklesia_core_grant_user_permission_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "permission_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_grant",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_grant_source_kind_check": {
|
||||
"name": "ekklesia_core_grant_source_kind_check",
|
||||
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||
},
|
||||
"ekklesia_core_grant_created_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner": {
|
||||
"name": "ekklesia_core_platform_owner",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_at": {
|
||||
"name": "granted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"granted_by_actor_type": {
|
||||
"name": "granted_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_by_actor_id": {
|
||||
"name": "granted_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_platform_owner",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"consumed_for_user_id": {
|
||||
"name": "consumed_for_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_group": {
|
||||
"name": "ekklesia_core_dashboard_group",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_group_order_idx": {
|
||||
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_group_id_check": {
|
||||
"name": "ekklesia_core_dashboard_group_id_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||
},
|
||||
"ekklesia_core_dashboard_group_label_check": {
|
||||
"name": "ekklesia_core_dashboard_group_label_check",
|
||||
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_placement": {
|
||||
"name": "ekklesia_core_dashboard_placement",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"group_id": {
|
||||
"name": "group_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_placement_group_idx": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "group_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||
"tableTo": "ekklesia_core_dashboard_group",
|
||||
"columnsFrom": ["group_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_placement_module_check": {
|
||||
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_module_state": {
|
||||
"name": "ekklesia_core_module_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_module_state_module_check": {
|
||||
"name": "ekklesia_core_module_state_module_check",
|
||||
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
{
|
||||
"id": "d41e490c-dbe1-4d73-9e11-6e79baa91908",
|
||||
"prevId": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ekklesia_core_account": {
|
||||
"name": "ekklesia_core_account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_account_user_id_idx": {
|
||||
"name": "ekklesia_core_account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_account",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_session": {
|
||||
"name": "ekklesia_core_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_session_user_id_idx": {
|
||||
"name": "ekklesia_core_session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_session",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_session_token_unique": {
|
||||
"name": "ekklesia_core_session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_user": {
|
||||
"name": "ekklesia_core_user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_user_email_unique": {
|
||||
"name": "ekklesia_core_user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_verification": {
|
||||
"name": "ekklesia_core_verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_verification_identifier_idx": {
|
||||
"name": "ekklesia_core_verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_authorization_audit": {
|
||||
"name": "ekklesia_core_authorization_audit",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_user_id": {
|
||||
"name": "subject_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_authorization_audit_subject_idx": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "subject_user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_authorization_audit_action_check": {
|
||||
"name": "ekklesia_core_authorization_audit_action_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_scope_check": {
|
||||
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_grant": {
|
||||
"name": "ekklesia_core_grant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_kind": {
|
||||
"name": "source_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_ref": {
|
||||
"name": "source_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_by_actor_type": {
|
||||
"name": "created_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_actor_id": {
|
||||
"name": "created_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_grant_user_permission_idx": {
|
||||
"name": "ekklesia_core_grant_user_permission_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "permission_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_grant",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_grant_source_kind_check": {
|
||||
"name": "ekklesia_core_grant_source_kind_check",
|
||||
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||
},
|
||||
"ekklesia_core_grant_created_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner": {
|
||||
"name": "ekklesia_core_platform_owner",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_at": {
|
||||
"name": "granted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"granted_by_actor_type": {
|
||||
"name": "granted_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_by_actor_id": {
|
||||
"name": "granted_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_platform_owner",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"consumed_for_user_id": {
|
||||
"name": "consumed_for_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_group": {
|
||||
"name": "ekklesia_core_dashboard_group",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_group_order_idx": {
|
||||
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_group_id_check": {
|
||||
"name": "ekklesia_core_dashboard_group_id_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||
},
|
||||
"ekklesia_core_dashboard_group_label_check": {
|
||||
"name": "ekklesia_core_dashboard_group_label_check",
|
||||
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_dashboard_placement": {
|
||||
"name": "ekklesia_core_dashboard_placement",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"group_id": {
|
||||
"name": "group_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_dashboard_placement_group_idx": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "group_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||
"tableTo": "ekklesia_core_dashboard_group",
|
||||
"columnsFrom": ["group_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_dashboard_placement_module_check": {
|
||||
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_module_state": {
|
||||
"name": "ekklesia_core_module_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_module_state_module_check": {
|
||||
"name": "ekklesia_core_module_state_module_check",
|
||||
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_module_configuration": {
|
||||
"name": "ekklesia_core_module_configuration",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"module_id": {
|
||||
"name": "module_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_module_configuration_module_idx": {
|
||||
"name": "ekklesia_core_module_configuration_module_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "module_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"ekklesia_core_module_configuration_module_id_key_pk": {
|
||||
"name": "ekklesia_core_module_configuration_module_id_key_pk",
|
||||
"columns": ["module_id", "key"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_module_configuration_module_check": {
|
||||
"name": "ekklesia_core_module_configuration_module_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0"
|
||||
},
|
||||
"ekklesia_core_module_configuration_key_check": {
|
||||
"name": "ekklesia_core_module_configuration_key_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0"
|
||||
},
|
||||
"ekklesia_core_module_configuration_value_check": {
|
||||
"name": "ekklesia_core_module_configuration_value_check",
|
||||
"value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,42 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0002_authorization_integrity",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0003_dashboard_navigation_tables",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0004_module_lifecycle_state",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0005_module_configuration",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0006_device_identity",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,48 @@
|
||||
"when": 1784819635300,
|
||||
"tag": "0001_authorization_core_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785104670124,
|
||||
"tag": "0002_authorization_integrity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1787408382271,
|
||||
"tag": "0003_dashboard_navigation_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1787423121678,
|
||||
"tag": "0004_module_lifecycle_state",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+28
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -15,6 +16,14 @@
|
||||
"types": "./dist/modules/index.d.ts",
|
||||
"import": "./dist/modules/index.js"
|
||||
},
|
||||
"./storage": {
|
||||
"types": "./dist/storage/index.d.ts",
|
||||
"import": "./dist/storage/index.js"
|
||||
},
|
||||
"./dashboard": {
|
||||
"types": "./dist/dashboard/index.d.ts",
|
||||
"import": "./dist/dashboard/index.js"
|
||||
},
|
||||
"./migrations": {
|
||||
"types": "./dist/migrations/index.d.ts",
|
||||
"import": "./dist/migrations/index.js"
|
||||
@@ -34,6 +43,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": {
|
||||
@@ -45,17 +66,23 @@
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1116.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1116.0",
|
||||
"@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"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js";
|
||||
import {
|
||||
ekklesiaCoreAuthorizationAudit,
|
||||
ekklesiaCorePlatformOwner,
|
||||
ekklesiaCorePlatformOwnerBootstrap,
|
||||
PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
|
||||
} from "./schema.js";
|
||||
|
||||
export type PlatformOwnerBootstrapErrorCode =
|
||||
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||
@@ -54,17 +59,25 @@ export interface BootstrapPlatformOwnerInput {
|
||||
|
||||
/**
|
||||
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
|
||||
* composition of "check, then call the existing grant function": the single-use check, the target
|
||||
* composition of "check, then call the existing grant function": the single-use claim, the target
|
||||
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
|
||||
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
|
||||
* statement — so two concurrent callers can never both observe "no owner yet" and both insert.
|
||||
* statement — so two concurrent callers can never both observe "not yet consumed" and both insert.
|
||||
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
|
||||
* re-evaluates against the now-current state.
|
||||
*
|
||||
* "Already consumed" is global and permanent: it is true the moment
|
||||
* `ekklesia_core_platform_owner` has ever held any row for any user, including one whose
|
||||
* `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a
|
||||
* platform owner would ever be re-established after a revocation, not this command.
|
||||
* **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued
|
||||
* existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so
|
||||
* treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's
|
||||
* user row silently reopened the one operation that establishes a platform's first administrator —
|
||||
* along with the audit evidence that it had ever run. The dedicated singleton has no user foreign
|
||||
* key, is never deleted by Core, and therefore survives owner revocation and user deletion alike.
|
||||
*
|
||||
* Consumption is deliberately tied to *success*: the sentinel is written inside the same
|
||||
* transaction as the owner row and its audit entry, so a missing target user, a constraint
|
||||
* violation, or any other failure rolls the sentinel back with everything else and leaves the one
|
||||
* legitimate first use still available. There is no reset command, deletion path, HTTP endpoint,
|
||||
* environment override, or force flag — by design, "consumed" is permanent.
|
||||
*/
|
||||
export async function bootstrapPlatformOwner(
|
||||
db: AuthorizationDrizzleDatabase,
|
||||
@@ -76,11 +89,11 @@ export async function bootstrapPlatformOwner(
|
||||
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
||||
);
|
||||
|
||||
const existingOwnerRows = await tx
|
||||
.select({ userId: ekklesiaCorePlatformOwner.userId })
|
||||
.from(ekklesiaCorePlatformOwner)
|
||||
const consumedRows = await tx
|
||||
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||
.limit(1);
|
||||
if (existingOwnerRows.length > 0) {
|
||||
if (consumedRows.length > 0) {
|
||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
|
||||
}
|
||||
|
||||
@@ -93,6 +106,15 @@ export async function bootstrapPlatformOwner(
|
||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
// The sentinel is claimed inside the same transaction as everything below it. The advisory
|
||||
// lock already serializes concurrent callers; the singleton primary key is the independent,
|
||||
// structural backstop that makes a second row impossible even if a future caller reached
|
||||
// this insert without the lock.
|
||||
await tx.insert(ekklesiaCorePlatformOwnerBootstrap).values({
|
||||
consumedForUserId: input.userId,
|
||||
id: PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
|
||||
});
|
||||
|
||||
await tx.insert(ekklesiaCorePlatformOwner).values({
|
||||
grantedByActorId: BOOTSTRAP_ACTOR_ID,
|
||||
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { Pool, type PoolConfig } from "pg";
|
||||
import {
|
||||
type DatabaseTransportPolicy,
|
||||
databaseTlsConnectionOptions,
|
||||
} from "../database/transport.js";
|
||||
import { authorizationSchema } from "./schema.js";
|
||||
|
||||
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
|
||||
@@ -27,6 +31,10 @@ export interface AuthorizationDatabaseOptions {
|
||||
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
|
||||
* connection, not a hung query already running on one. */
|
||||
readonly operationTimeoutMs?: number;
|
||||
/** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by
|
||||
* every production executable; omitted by single-domain unit/integration factories that use
|
||||
* the disposable loopback database, which keeps the pre-existing plaintext behavior. */
|
||||
readonly transport?: DatabaseTransportPolicy;
|
||||
}
|
||||
|
||||
export interface AuthorizationDatabaseHandle {
|
||||
@@ -80,8 +88,13 @@ export function createAuthorizationDrizzleDatabase(
|
||||
const validatedConnectionString = validateConnectionString(connectionString);
|
||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||
const ssl =
|
||||
options.transport === undefined
|
||||
? undefined
|
||||
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
|
||||
const poolConfig: PoolConfig = {
|
||||
connectionString: validatedConnectionString,
|
||||
...(ssl === undefined ? {} : { ssl }),
|
||||
...(validatedConnectionTimeoutMs === undefined
|
||||
? {}
|
||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -83,6 +83,92 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.authorization.grants.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* Authenticated: any signed-in user of this installation may learn which module UIs the platform is
|
||||
* currently hosting, because that is exactly what a browser shell has to render before it can offer
|
||||
* anything at all. It is deliberately not `public` — the anonymous artifact route (ADR 0025) is
|
||||
* careful never to become an enumeration oracle, and an anonymous inventory would hand over the very
|
||||
* listing that route withholds. It is deliberately not `restricted` either: a shell that rendered
|
||||
* nothing until an operator granted every user this permission would make the platform unusable for
|
||||
* its ordinary users, and the module UIs themselves already gate their own content on their own
|
||||
* permissions.
|
||||
*
|
||||
* Listing a module UI is not permission to use that module. Every module route remains enforced
|
||||
* against its own declared permission, unchanged.
|
||||
*/
|
||||
export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read");
|
||||
|
||||
/**
|
||||
* Restricted: reorganizing the Dashboard is an administrative action, not something every signed-in
|
||||
* user may do. It is deliberately a **platform** permission and never a module's own — grouping is
|
||||
* Ekklesia's presentation configuration, so no module may gate, grant, or influence it (ADR 0027).
|
||||
*
|
||||
* Holding it confers no module authority whatsoever. Moving a module into a folder changes a
|
||||
* presentation row; it grants nobody access to that module's data, and every module route stays
|
||||
* enforced against its own declared permission exactly as before.
|
||||
*/
|
||||
export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.dashboard.navigation.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* Restricted, and it gates **reading as well as changing**. Deciding which installed modules run is
|
||||
* an administrative operation, and the read is not a milder version of it: the lifecycle view is the
|
||||
* only place a *disabled* module, a *blocked* module, and the required-dependency edges between them
|
||||
* are visible at all. `core.modules.ui.read` deliberately exposes the mountable set to every signed-in
|
||||
* user; this exposes the parts of the installation that are deliberately not running, which is not
|
||||
* the same audience. A second, weaker read permission over the same resource would be ceremony
|
||||
* (ADR 0027's reasoning, applied in the other direction).
|
||||
*
|
||||
* It is a **platform** permission and never a module's. A module can neither declare it, gate it, nor
|
||||
* observe it: whether a module runs is a decision its host makes about it, not a decision it takes
|
||||
* part in (ADR 0028). Holding it confers no authority inside any module — every module route stays
|
||||
* enforced against that module's own declared permission.
|
||||
*/
|
||||
export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.modules.lifecycle.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize
|
||||
* genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides
|
||||
* *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted
|
||||
* to take modules offline, and the reverse is equally true, so folding them into one permission
|
||||
* would grant authority nobody asked for in whichever direction it was used (ADR 0029).
|
||||
*
|
||||
* It gates reading as well as writing, because the read returns the module's declared schema
|
||||
* together with every stored value — endpoints, bucket names, and whatever else an operator has
|
||||
* configured. That is deployment detail, not something every signed-in user needs.
|
||||
*
|
||||
* **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never
|
||||
* persisted, and is never returned by any route; the API reports only whether one is configured.
|
||||
* Holding this permission therefore confers no ability to read a credential — there is no code path
|
||||
* that would serve one.
|
||||
*
|
||||
* Like every other Core permission it is a **platform** permission a module can neither declare,
|
||||
* gate, nor observe, and holding it confers no authority inside any module.
|
||||
*/
|
||||
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
|
||||
@@ -96,6 +182,31 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
||||
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "authenticated" as const,
|
||||
description: "List the installed module UIs a browser host may mount.",
|
||||
id: CORE_MODULES_UI_READ,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
||||
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
description: "Read and change which installed modules are enabled.",
|
||||
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -72,11 +72,20 @@ export const ekklesiaCorePlatformOwner = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* Append-only: no update or delete path is ever exposed by Core's authorization store. `scope`
|
||||
* intentionally rejects an empty (but non-null) string — defense in depth matching the
|
||||
* application-layer `AuthorizationScope` contract, which never produces one — so a coding defect
|
||||
* that slipped an empty scope past that validation fails the whole grant-plus-audit transaction
|
||||
* atomically instead of recording a meaningless audit entry.
|
||||
* Append-only *by application convention*: Core's authorization store exposes no update or delete
|
||||
* path for these rows. That is a property of this codebase, not a database guarantee — no
|
||||
* PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or
|
||||
* anything else holding the same credentials from modifying them. `scope` intentionally rejects an
|
||||
* empty (but non-null) string — defense in depth matching the application-layer
|
||||
* `AuthorizationScope` contract, which never produces one — so a coding defect that slipped an
|
||||
* empty scope past that validation fails the whole grant-plus-audit transaction atomically instead
|
||||
* of recording a meaningless audit entry.
|
||||
*
|
||||
* `subject_user_id` is a **retained historical snapshot**, deliberately carrying no foreign key to
|
||||
* the live Better Auth user row. It previously cascaded on user deletion, which meant deleting a
|
||||
* user destroyed exactly the security history describing what that user had been granted and who
|
||||
* granted it — the audit trail disappeared precisely when it mattered most. The value stays a
|
||||
* plain, non-null identifier string; no name, email, or other profile data is ever copied here.
|
||||
*/
|
||||
export const ekklesiaCoreAuthorizationAudit = pgTable(
|
||||
"ekklesia_core_authorization_audit",
|
||||
@@ -85,9 +94,7 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
|
||||
action: text("action").notNull(),
|
||||
actorType: text("actor_type").notNull(),
|
||||
actorId: text("actor_id"),
|
||||
subjectUserId: text("subject_user_id")
|
||||
.notNull()
|
||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||
subjectUserId: text("subject_user_id").notNull(),
|
||||
permissionId: text("permission_id"),
|
||||
scope: text("scope"),
|
||||
grantId: uuid("grant_id"),
|
||||
@@ -110,6 +117,40 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* The durable, single-row record meaning "platform-owner bootstrap was successfully consumed."
|
||||
*
|
||||
* Bootstrap's single-use authority previously came from the mere existence of a row in
|
||||
* `ekklesia_core_platform_owner` — which cascades on user deletion, so deleting the bootstrapped
|
||||
* owner's user row silently reopened the one-time operation that establishes the platform's first
|
||||
* administrator. This table exists specifically so that authority survives owner revocation and
|
||||
* user deletion: it has **no foreign key to any user**, and nothing in Core ever deletes from it.
|
||||
*
|
||||
* `id` is pinned by a check constraint to the single literal value `'singleton'`, so the primary
|
||||
* key makes a second row structurally impossible rather than merely unwritten by convention —
|
||||
* concurrent first attempts collide on the primary key inside their transactions.
|
||||
*
|
||||
* `consumed_for_user_id` is an optional historical snapshot of who bootstrap made an owner, in the
|
||||
* same retained-identifier style as the audit subject and carrying no profile data. It is null for
|
||||
* an installation whose consumed state was backfilled by migration, because a backfill cannot know
|
||||
* which of several historical owner rows the original single use actually targeted, and guessing
|
||||
* one would fabricate history.
|
||||
*/
|
||||
export const ekklesiaCorePlatformOwnerBootstrap = pgTable(
|
||||
"ekklesia_core_platform_owner_bootstrap",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
consumedAt: timestamp("consumed_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
consumedForUserId: text("consumed_for_user_id"),
|
||||
},
|
||||
(table) => [
|
||||
check("ekklesia_core_platform_owner_bootstrap_singleton_check", sql`${table.id} = 'singleton'`),
|
||||
],
|
||||
);
|
||||
|
||||
/** The only value `ekklesia_core_platform_owner_bootstrap.id` may ever hold. */
|
||||
export const PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID = "singleton";
|
||||
|
||||
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
|
||||
user: one(ekklesiaCoreUser, {
|
||||
fields: [ekklesiaCoreGrant.userId],
|
||||
@@ -127,19 +168,14 @@ export const ekklesiaCorePlatformOwnerRelations = relations(
|
||||
}),
|
||||
);
|
||||
|
||||
export const ekklesiaCoreAuthorizationAuditRelations = relations(
|
||||
ekklesiaCoreAuthorizationAudit,
|
||||
({ one }) => ({
|
||||
subject: one(ekklesiaCoreUser, {
|
||||
fields: [ekklesiaCoreAuthorizationAudit.subjectUserId],
|
||||
references: [ekklesiaCoreUser.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table:
|
||||
// its subject is a retained historical identifier that may name a user row that no longer exists,
|
||||
// so modelling it as a live relation would misdescribe the data.
|
||||
|
||||
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
|
||||
export const authorizationSchema = {
|
||||
authorizationAudit: ekklesiaCoreAuthorizationAudit,
|
||||
grant: ekklesiaCoreGrant,
|
||||
platformOwner: ekklesiaCorePlatformOwner,
|
||||
platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap,
|
||||
} as const;
|
||||
|
||||
+296
-31
@@ -28,7 +28,10 @@ import {
|
||||
} from "./schema.js";
|
||||
|
||||
export type AuthorizationStoreErrorCode =
|
||||
| "AUTHORIZATION_GRANT_ALREADY_EXISTS"
|
||||
| "AUTHORIZATION_GRANT_CREATE_FAILED"
|
||||
| "AUTHORIZATION_GRANT_EXPIRATION_INVALID"
|
||||
| "AUTHORIZATION_GRANT_LIMIT_INVALID"
|
||||
| "AUTHORIZATION_GRANT_LIST_FAILED"
|
||||
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
|
||||
| "AUTHORIZATION_GRANT_NOT_FOUND"
|
||||
@@ -38,7 +41,12 @@ export type AuthorizationStoreErrorCode =
|
||||
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
|
||||
|
||||
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
||||
AUTHORIZATION_GRANT_ALREADY_EXISTS:
|
||||
"An equivalent authorization grant is already effective for this user.",
|
||||
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
||||
AUTHORIZATION_GRANT_EXPIRATION_INVALID:
|
||||
"The authorization grant expiration must be a valid instant in the future.",
|
||||
AUTHORIZATION_GRANT_LIMIT_INVALID: "The authorization grant page size is invalid.",
|
||||
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
|
||||
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
|
||||
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
|
||||
@@ -49,6 +57,10 @@ const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>>
|
||||
"The platform-owner record was not found or is already revoked.",
|
||||
});
|
||||
|
||||
/** The largest page any caller may request from `listGrantsForUser`, enforced identically by the
|
||||
* store and by the HTTP surface so there is exactly one bound, not two that could drift. */
|
||||
export const AUTHORIZATION_GRANT_LIST_MAX_LIMIT = 100;
|
||||
|
||||
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||
export class AuthorizationStoreError extends Error {
|
||||
readonly code: AuthorizationStoreErrorCode;
|
||||
@@ -101,11 +113,38 @@ export interface RevokeGrantInput {
|
||||
readonly sourceKind?: GrantSourceKind;
|
||||
}
|
||||
|
||||
/** One row's position in the `created_at DESC, id DESC` ordering, used as a strict keyset
|
||||
* boundary. Both values are required: comparing on the timestamp alone would skip or repeat rows
|
||||
* whenever two grants share a `created_at`, which concurrent creation makes routine. */
|
||||
export interface GrantPageBoundary {
|
||||
readonly createdAt: Date;
|
||||
readonly id: GrantId;
|
||||
}
|
||||
|
||||
export interface ListGrantsForUserOptions {
|
||||
/** Required. The maximum number of rows to return, 1 through
|
||||
* `AUTHORIZATION_GRANT_LIST_MAX_LIMIT`. There is deliberately no default and no unbounded
|
||||
* fallback anywhere: an unbounded listing returned a user's entire authorization history in one
|
||||
* response, and a bound a caller may simply omit is not a bound. */
|
||||
readonly limit: number;
|
||||
/** When provided, only grants strictly *after* this position in the ordering are returned. */
|
||||
readonly after?: GrantPageBoundary;
|
||||
/** When provided, only grants with this exact `sourceKind` are returned. */
|
||||
readonly sourceKind?: GrantSourceKind;
|
||||
}
|
||||
|
||||
export interface GrantPage {
|
||||
readonly grants: readonly GrantRecord[];
|
||||
/** The last returned row's position when more rows exist, otherwise `undefined`. */
|
||||
readonly nextBoundary?: GrantPageBoundary;
|
||||
}
|
||||
|
||||
export interface CreateAuthorizationStoreOptions {
|
||||
/** Injected only so expiration validation is deterministic under test. Production composition
|
||||
* leaves this unset and the store reads real time. */
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
|
||||
* append-only audit log, all behind one explicit factory — no environment reads, no connection
|
||||
@@ -117,12 +156,9 @@ export interface ListGrantsForUserOptions {
|
||||
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
|
||||
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
|
||||
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
||||
/** Every grant ever recorded for the user, active or revoked, newest first — the caller decides
|
||||
* what to display. Never includes another user's grants. */
|
||||
listGrantsForUser(
|
||||
userId: EkklesiaUserId,
|
||||
options?: ListGrantsForUserOptions,
|
||||
): Promise<readonly GrantRecord[]>;
|
||||
/** One bounded page of the user's grants, active or revoked, ordered `created_at DESC, id DESC`.
|
||||
* Never includes another user's grants. */
|
||||
listGrantsForUser(userId: EkklesiaUserId, options: ListGrantsForUserOptions): Promise<GrantPage>;
|
||||
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
}
|
||||
@@ -160,11 +196,141 @@ function toGrantRecord(row: GrantRow): GrantRecord {
|
||||
});
|
||||
}
|
||||
|
||||
export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore {
|
||||
/**
|
||||
* The five values that make two grant issuances the *same authorization source*: subject,
|
||||
* permission, scope, source kind, and source reference — with `NULL` equal only to `NULL`.
|
||||
* `expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
|
||||
* distinguish them: re-issuing the identical authority with a different expiry or by a different
|
||||
* administrator is the same grant, not a second one.
|
||||
*/
|
||||
interface GrantEquivalence {
|
||||
readonly userId: string;
|
||||
readonly permissionId: string;
|
||||
readonly scope: string | null;
|
||||
readonly sourceKind: string;
|
||||
readonly sourceRef: string | null;
|
||||
}
|
||||
|
||||
function equivalenceOf(input: CreateGrantInput): GrantEquivalence {
|
||||
return {
|
||||
permissionId: input.permission,
|
||||
scope: input.scope ?? null,
|
||||
sourceKind: input.sourceKind,
|
||||
sourceRef: input.sourceRef ?? null,
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes every create/revoke touching one equivalence tuple against a transaction-scoped
|
||||
* PostgreSQL advisory lock, so concurrent callers cannot both observe "no effective equivalent"
|
||||
* and both insert. A process-local mutex would not do this — nothing constrains the number of API
|
||||
* processes — and neither would an unlocked check-then-insert.
|
||||
*
|
||||
* The key is a 64-bit hash of the tuple encoded as a JSON array, which keeps `NULL` and the empty
|
||||
* string distinct and makes separator forgery through a crafted scope or source reference
|
||||
* impossible. A hash collision is harmless by construction: it can only make two unrelated tuples
|
||||
* serialize against each other, never make a real conflict go unnoticed, because the authoritative
|
||||
* decision is always the exact SQL predicate below and never the hash.
|
||||
*
|
||||
* `pg_advisory_xact_lock(bigint)` uses PostgreSQL's *single-key* advisory-lock space, which is
|
||||
* verified to be disjoint from the two-key space used by the migration runner
|
||||
* (`core/src/migrations/postgres-database.ts`) and by platform-owner bootstrap. Grant serialization
|
||||
* therefore can never block, or be blocked by, migration or bootstrap locking. Being
|
||||
* transaction-scoped, it is released on COMMIT or ROLLBACK with no manual unlock.
|
||||
*/
|
||||
async function lockEquivalence(
|
||||
tx: Parameters<Parameters<AuthorizationDrizzleDatabase["transaction"]>[0]>[0],
|
||||
equivalence: GrantEquivalence,
|
||||
): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
|
||||
${equivalence.userId}::text,
|
||||
${equivalence.permissionId}::text,
|
||||
${equivalence.scope}::text,
|
||||
${equivalence.sourceKind}::text,
|
||||
${equivalence.sourceRef}::text
|
||||
)::text, 0))`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches exactly the rows sharing an equivalence tuple. `is not distinct from` is what gives
|
||||
* `NULL = NULL` for the two nullable members; plain `=` would silently match nothing. */
|
||||
function equivalenceCondition(equivalence: GrantEquivalence) {
|
||||
return and(
|
||||
eq(ekklesiaCoreGrant.userId, equivalence.userId),
|
||||
eq(ekklesiaCoreGrant.permissionId, equivalence.permissionId),
|
||||
sql`${ekklesiaCoreGrant.scope} is not distinct from ${equivalence.scope}::text`,
|
||||
eq(ekklesiaCoreGrant.sourceKind, equivalence.sourceKind),
|
||||
sql`${ekklesiaCoreGrant.sourceRef} is not distinct from ${equivalence.sourceRef}::text`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A supplied expiration must be a real instant strictly later than the acceptance clock. A past
|
||||
* or equal-to-now expiration produces a grant that is dead the moment it is written, which is
|
||||
* never what a caller meant; rejecting it here means trusted internal callers get the same
|
||||
* guarantee the HTTP surface enforces, rather than each validating separately. */
|
||||
function assertValidExpiration(expiresAt: Date | undefined, now: Date): void {
|
||||
if (expiresAt === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(expiresAt instanceof Date) ||
|
||||
Number.isNaN(expiresAt.getTime()) ||
|
||||
expiresAt.getTime() <= now.getTime()
|
||||
) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_EXPIRATION_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidLimit(limit: number): number {
|
||||
if (
|
||||
typeof limit !== "number" ||
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT
|
||||
) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIMIT_INVALID");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
export function createAuthorizationStore(
|
||||
db: AuthorizationDrizzleDatabase,
|
||||
options: CreateAuthorizationStoreOptions = {},
|
||||
): AuthorizationStore {
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
|
||||
return Object.freeze({
|
||||
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
|
||||
// Validated before any transaction opens, so a rejected expiration performs no database work
|
||||
// at all and can never leave a grant or an audit row behind.
|
||||
assertValidExpiration(input.expiresAt, now());
|
||||
const equivalence = equivalenceOf(input);
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
await lockEquivalence(tx, equivalence);
|
||||
|
||||
// Under the lock, "currently effective" is authoritative: an equivalent that is revoked,
|
||||
// or whose expiry has already passed, does not block a fresh issuance.
|
||||
const effective = await tx
|
||||
.select({ id: ekklesiaCoreGrant.id })
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
equivalenceCondition(equivalence),
|
||||
isNull(ekklesiaCoreGrant.revokedAt),
|
||||
or(
|
||||
isNull(ekklesiaCoreGrant.expiresAt),
|
||||
gt(ekklesiaCoreGrant.expiresAt, sql`now()`),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (effective.length > 0) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS");
|
||||
}
|
||||
|
||||
const [row] = await tx
|
||||
.insert(ekklesiaCoreGrant)
|
||||
.values({
|
||||
@@ -202,16 +368,33 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoking one grant revokes *every* currently unrevoked issuance sharing its equivalence
|
||||
* tuple, in one transaction, with one `grant.revoke` audit row per grant actually changed.
|
||||
*
|
||||
* Addressing a single id and revoking only that row was actively misleading: an operator who
|
||||
* revoked a grant received `success` while an equivalent duplicate kept authorizing the same
|
||||
* user. Expired-but-unrevoked equivalents are included deliberately — they are already
|
||||
* ineffective, but leaving them unrevoked would keep the history in two states for what is one
|
||||
* authorization source. Grants with a different subject, permission, scope, source kind, or
|
||||
* source reference are untouched.
|
||||
*/
|
||||
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(ekklesiaCoreGrant)
|
||||
.set({
|
||||
revokedAt: sql`now()`,
|
||||
revokedByActorId: actorId(input.actor),
|
||||
revokedByActorType: input.actor.type,
|
||||
// This first read exists only to discover the equivalence tuple, which is safe to read
|
||||
// before the lock because those columns are immutable once written. It deliberately does
|
||||
// *not* settle whether the revocation may proceed: everything it observed can change
|
||||
// while this transaction waits for the lock below.
|
||||
const [target] = await tx
|
||||
.select({
|
||||
permissionId: ekklesiaCoreGrant.permissionId,
|
||||
scope: ekklesiaCoreGrant.scope,
|
||||
sourceKind: ekklesiaCoreGrant.sourceKind,
|
||||
sourceRef: ekklesiaCoreGrant.sourceRef,
|
||||
userId: ekklesiaCoreGrant.userId,
|
||||
})
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||
@@ -221,20 +404,74 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!row) {
|
||||
.limit(1);
|
||||
if (!target) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "grant.revoke",
|
||||
actorId: actorId(input.actor),
|
||||
actorType: input.actor.type,
|
||||
grantId: row.id,
|
||||
permissionId: row.permissionId,
|
||||
scope: row.scope,
|
||||
subjectUserId: row.userId,
|
||||
});
|
||||
await lockEquivalence(tx, target);
|
||||
|
||||
// Revalidate the *addressed* grant now that the lock is held. Without this, a revocation
|
||||
// that paused between the read above and the lock could wake after its target was
|
||||
// revoked and a fresh equivalent created, and the tuple-matching update below would
|
||||
// silently revoke that replacement — reporting success for a grant the caller never
|
||||
// addressed, in an order no serial execution could produce, and losing the not-found
|
||||
// result an already-revoked target must return.
|
||||
//
|
||||
// The check is deliberately narrow and re-asserts every condition the pre-lock read
|
||||
// relied on: the exact addressed id, still unrevoked, still satisfying the caller's
|
||||
// explicit/relationship source guard, and still carrying the same equivalence tuple the
|
||||
// lock was taken for. It is a plain read, not `for update`: the tuple advisory lock
|
||||
// remains the one create/revoke serialization order, so no second lock ordering — and no
|
||||
// deadlock path — is introduced.
|
||||
const [addressed] = await tx
|
||||
.select({ id: ekklesiaCoreGrant.id })
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||
isNull(ekklesiaCoreGrant.revokedAt),
|
||||
...(input.sourceKind === undefined
|
||||
? []
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||
equivalenceCondition(target),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!addressed) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||
}
|
||||
|
||||
// Only now is the whole equivalence class cleared, which is what still removes genuine
|
||||
// legacy duplicates — including expired-but-unrevoked ones. Narrowing this update to the
|
||||
// addressed id instead would not have been a fix; it would have dropped that behavior.
|
||||
const rows = await tx
|
||||
.update(ekklesiaCoreGrant)
|
||||
.set({
|
||||
revokedAt: sql`now()`,
|
||||
revokedByActorId: actorId(input.actor),
|
||||
revokedByActorType: input.actor.type,
|
||||
})
|
||||
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
|
||||
.returning();
|
||||
if (rows.length === 0) {
|
||||
// Unreachable while the lock is held, since the row confirmed above satisfies this
|
||||
// same predicate — kept as a fail-closed guard against a writer that bypasses the
|
||||
// lock, rather than silently auditing nothing.
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values(
|
||||
rows.map((row) => ({
|
||||
action: "grant.revoke",
|
||||
actorId: actorId(input.actor),
|
||||
actorType: input.actor.type,
|
||||
grantId: row.id,
|
||||
permissionId: row.permissionId,
|
||||
scope: row.scope,
|
||||
subjectUserId: row.userId,
|
||||
})),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
@@ -269,10 +506,21 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deterministic keyset pagination over `created_at DESC, id DESC`. The boundary is a strict
|
||||
* row-value comparison on *both* columns, so grants sharing a `created_at` — which concurrent
|
||||
* creation makes routine — can neither be skipped nor repeated across pages. Offset pagination
|
||||
* would do exactly that under concurrent writes, which is why it is not used.
|
||||
*
|
||||
* At most `limit + 1` rows are read: the extra row only answers "is there another page" and is
|
||||
* never returned.
|
||||
*/
|
||||
listGrantsForUser: async (
|
||||
userId: EkklesiaUserId,
|
||||
options: ListGrantsForUserOptions = {},
|
||||
): Promise<readonly GrantRecord[]> => {
|
||||
listOptions: ListGrantsForUserOptions,
|
||||
): Promise<GrantPage> => {
|
||||
const limit = assertValidLimit(listOptions.limit);
|
||||
const after = listOptions.after;
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -280,14 +528,31 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.userId, userId),
|
||||
...(options.sourceKind === undefined
|
||||
...(listOptions.sourceKind === undefined
|
||||
? []
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]),
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, listOptions.sourceKind)]),
|
||||
...(after === undefined
|
||||
? []
|
||||
: [
|
||||
sql`(${ekklesiaCoreGrant.createdAt}, ${ekklesiaCoreGrant.id}) < (${after.createdAt}::timestamptz, ${after.id}::uuid)`,
|
||||
]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(ekklesiaCoreGrant.createdAt));
|
||||
return Object.freeze(rows.map(toGrantRecord));
|
||||
} catch {
|
||||
.orderBy(desc(ekklesiaCoreGrant.createdAt), desc(ekklesiaCoreGrant.id))
|
||||
.limit(limit + 1);
|
||||
|
||||
const page = rows.slice(0, limit).map(toGrantRecord);
|
||||
const last = page.at(-1);
|
||||
return Object.freeze({
|
||||
grants: Object.freeze(page),
|
||||
...(rows.length > limit && last !== undefined
|
||||
? { nextBoundary: Object.freeze({ createdAt: last.createdAt, id: last.id }) }
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { formatMigrationApplyReport } from "./migration-report.js";
|
||||
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
||||
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
||||
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||
import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const aggregate = await discoverRepositoryMigrations();
|
||||
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL);
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
// Resolved and asserted before any client exists: an unsafe production transport fails
|
||||
// here rather than after opening a connection.
|
||||
const transport =
|
||||
typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined;
|
||||
const database = createPostgresMigrationDatabase(databaseUrl, {
|
||||
...(transport === undefined ? {} : { transport }),
|
||||
});
|
||||
const result = await applyModuleMigrations({ aggregate, database });
|
||||
|
||||
for (const line of formatMigrationApplyReport(result)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createAuthorizationDrizzleDatabase,
|
||||
PlatformOwnerBootstrapError,
|
||||
} from "../authorization/index.js";
|
||||
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||
|
||||
class BootstrapCliError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -67,9 +68,13 @@ async function main(): Promise<void> {
|
||||
throw new BootstrapCliError("DATABASE_URL is required.");
|
||||
}
|
||||
|
||||
// Asserted before the connection is constructed, so an unsafe production transport never opens
|
||||
// a client at all.
|
||||
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
transport,
|
||||
});
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
assertProductionDatabaseTransport,
|
||||
type DatabaseTransportEnvironment,
|
||||
type DatabaseTransportPolicy,
|
||||
createDatabaseTransportPolicy,
|
||||
} from "../database/transport.js";
|
||||
import { loadHostConfiguration } from "../host/index.js";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 host = loadHostConfiguration(process.env);
|
||||
const transport = createDatabaseTransportPolicy(host.databaseTransport, {
|
||||
environment: host.environment,
|
||||
});
|
||||
return { environment: host.environment, transport };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the policy and asserts it is safe for the given database URL before any client, pool,
|
||||
* or connection is constructed. Throws the sanitized `DatabaseTransportError` otherwise.
|
||||
*/
|
||||
export function resolveExecutableDatabaseTransport(databaseUrl: string): DatabaseTransportPolicy {
|
||||
const { environment, transport } = readDatabaseTransportFromEnvironment();
|
||||
assertProductionDatabaseTransport(databaseUrl, transport, environment);
|
||||
return transport;
|
||||
}
|
||||
+73
-24
@@ -2,11 +2,13 @@ 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";
|
||||
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
|
||||
|
||||
interface ModuleSdkPackageDocument {
|
||||
readonly version?: unknown;
|
||||
@@ -18,6 +20,20 @@ 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 platformVersion: string;
|
||||
/** Where the platform is installed: Core's migrations live beneath it, and so does the
|
||||
* development default modules directory. Installed modules may live anywhere else. */
|
||||
readonly repositoryRoot: string;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
}
|
||||
|
||||
async function readJson(pathname: string): Promise<unknown> {
|
||||
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
||||
}
|
||||
@@ -36,22 +52,57 @@ 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 trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||
const trustedOrigins = trustedOriginsValue
|
||||
? trustedOriginsValue
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0)
|
||||
: undefined;
|
||||
/**
|
||||
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
|
||||
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
|
||||
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
|
||||
*/
|
||||
function doctorModulesDirectory(
|
||||
configured: string | undefined,
|
||||
repositoryRoot: string,
|
||||
): ModulesDirectorySelection {
|
||||
try {
|
||||
return resolveModulesDirectory({ configured, repositoryRoot });
|
||||
} catch {
|
||||
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
...(secret ? { secret } : {}),
|
||||
...(trustedOrigins ? { trustedOrigins } : {}),
|
||||
clientAddress: host.clientAddress,
|
||||
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
|
||||
databaseEnvironment: host.environment,
|
||||
databaseTransport: host.databaseTransport,
|
||||
databaseUrl: host.databaseUrl,
|
||||
identityConfiguration: { ...host.identity, environment: host.environment },
|
||||
mailConfiguration: host.mail,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: metadata.moduleSdkVersion,
|
||||
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
|
||||
platformVersion: metadata.platformVersion,
|
||||
},
|
||||
moduleSecrets: host.moduleSecrets,
|
||||
runtimeRange: metadata.runtimeRange,
|
||||
runtimeVersion: metadata.runtimeVersion,
|
||||
storageConfiguration: host.storage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,17 +114,15 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
||||
const rootPackage = rootPackageDocument(rootValue);
|
||||
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
||||
|
||||
const report = await diagnoseEkklesia({
|
||||
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,
|
||||
});
|
||||
repositoryRoot,
|
||||
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||
runtimeVersion: process.versions.node,
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(formatDoctorReport(report).join("\n"));
|
||||
return doctorExitCode(report);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { DatabaseTransportError } from "../database/transport.js";
|
||||
import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js";
|
||||
import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js";
|
||||
|
||||
export function reportMigrationCommandError(error: unknown): void {
|
||||
if (error instanceof DatabaseTransportError) {
|
||||
// The message is a fixed string chosen by code; it never carries the connection string, host,
|
||||
// port, credentials, CA material, or the offending mode value, so it is safe to print as-is —
|
||||
// and an operator who misconfigured TLS needs to know that, not "failed unexpectedly".
|
||||
console.error(`[${error.code}] ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ModuleDiscoveryError) {
|
||||
console.error(error.message);
|
||||
for (const issue of error.issues) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { HostEnvironment } from "../host/index.js";
|
||||
import { discoverAllMigrations } from "../migrations/index.js";
|
||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||
|
||||
interface PackageVersion {
|
||||
readonly version?: unknown;
|
||||
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
return packageData.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's migrations come from the platform (`<repositoryRoot>/core/drizzle`); installed modules'
|
||||
* come from the configured modules directory, wherever it is (ADR 0048). Neither is ever derived
|
||||
* from the other.
|
||||
*/
|
||||
export async function discoverRepositoryMigrations(
|
||||
repositoryRoot = process.cwd(),
|
||||
environment: HostEnvironment = process.env,
|
||||
): Promise<ModuleMigrationAggregate> {
|
||||
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
|
||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||
@@ -27,7 +36,7 @@ export async function discoverRepositoryMigrations(
|
||||
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
...modulesDirectory,
|
||||
platformVersion,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
|
||||
import type {
|
||||
MigrationApplyResult,
|
||||
MigrationPlanResult,
|
||||
RetainedMigrationHistory,
|
||||
} from "../migrations/index.js";
|
||||
|
||||
function plural(count: number, singular: string): string {
|
||||
return count === 1 ? singular : `${singular}s`;
|
||||
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
|
||||
return Object.freeze(lines);
|
||||
}
|
||||
|
||||
/** An absent module's applied history (ADR 0048): kept, reported, and never a reason to stop. */
|
||||
function retainedLines(retained: readonly RetainedMigrationHistory[]): readonly string[] {
|
||||
return retained.map(
|
||||
(module) =>
|
||||
`- [retained] ${module.moduleId}: ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
|
||||
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
|
||||
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
|
||||
const lines = [
|
||||
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
|
||||
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked${retainedSummary}):`,
|
||||
];
|
||||
|
||||
for (const entry of plan.entries) {
|
||||
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
|
||||
if (plan.entries.length === 0) {
|
||||
lines.push("- No installed module migrations.");
|
||||
}
|
||||
lines.push(...retainedLines(plan.retained));
|
||||
lines.push(
|
||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||
);
|
||||
@@ -49,7 +63,10 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
|
||||
|
||||
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
|
||||
if (result.noOp) {
|
||||
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
|
||||
return Object.freeze([
|
||||
"Migration apply completed: database is already current (no changes).",
|
||||
...retainedLines(result.retained),
|
||||
]);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
|
||||
for (const migration of result.applied) {
|
||||
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
|
||||
}
|
||||
lines.push(...retainedLines(result.retained));
|
||||
return Object.freeze(lines);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
||||
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
|
||||
|
||||
/**
|
||||
* The modules directory an operator command discovers, read through the same host configuration
|
||||
* `startApi` and Doctor use and resolved by the one Core resolver (ADR 0048). Throws the resolver's
|
||||
* sanitized `ModuleDiscoveryError` for an unusable value, which every command already reports.
|
||||
*/
|
||||
export function resolveCommandModulesDirectory(
|
||||
environment: HostEnvironment,
|
||||
repositoryRoot: string,
|
||||
): ModulesDirectorySelection {
|
||||
return resolveModulesDirectory({
|
||||
configured: loadHostConfiguration(environment).modulesDirectory,
|
||||
repositoryRoot,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user