Compare commits
268
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9dafcb0f7 | ||
|
|
300d9eab51 | ||
|
|
906c015ca8 | ||
|
|
5f9b536bc6 | ||
|
|
405e509bcb | ||
|
|
186668915b | ||
|
|
c78f1318b4 | ||
|
|
ae360ef318 | ||
|
|
4671622b9d | ||
|
|
7447381c8f | ||
|
|
cd0287537e | ||
|
|
8571e6854c | ||
|
|
89ef4b5ff6 | ||
|
|
34692ac05f | ||
|
|
d42b64a093 | ||
|
|
e3e2a901a7 | ||
|
|
5a7d44781b | ||
|
|
651efa8829 | ||
|
|
cf2f847c8c | ||
|
|
af31724cb4 | ||
|
|
f3b7e19651 | ||
|
|
ba60f70835 | ||
|
|
9afe9376c4 | ||
|
|
44430b5d59 | ||
|
|
bef516796e | ||
|
|
9bef1d9ef2 | ||
|
|
f2fc069e66 | ||
|
|
0552442c7c | ||
|
|
63fda0f8d6 | ||
|
|
94fa7a17a8 | ||
|
|
437720f228 | ||
|
|
d3148d45f7 | ||
|
|
303d455315 | ||
|
|
39f48dc7c9 | ||
|
|
9d515cd56f | ||
|
|
b8a826f8be | ||
|
|
0c9b8f7af9 | ||
|
|
5e9433d96b | ||
|
|
650932a170 | ||
|
|
2d861c6bf0 | ||
|
|
bae4ff3c6a | ||
|
|
9331a8f7df | ||
|
|
8f90871be0 | ||
|
|
85b1377fd3 | ||
|
|
4bcdb578e6 | ||
|
|
06bad2fe36 | ||
|
|
ae976ca229 | ||
|
|
f9585cc7ec | ||
|
|
6dae29412f | ||
|
|
40ef0ff802 | ||
|
|
a0e9948820 | ||
|
|
6e64b3c5f9 | ||
|
|
fa27603133 | ||
|
|
9e01679fcb | ||
|
|
227c92e35d | ||
|
|
505c9e8c0a | ||
|
|
4be046c8d0 | ||
|
|
c5a6cdaf6a | ||
|
|
c4fded6ecf | ||
|
|
9ad2d5b564 | ||
|
|
98d93cc12d | ||
|
|
e083c44e05 | ||
|
|
4919db6168 | ||
|
|
6e76293620 | ||
|
|
3b54a7f023 | ||
|
|
afaf3a853c | ||
|
|
1788d17f0a | ||
|
|
1caffc5f26 | ||
|
|
debb8df3ce | ||
|
|
98e59d4ba0 | ||
|
|
22404ea2d4 | ||
|
|
486356a01e | ||
|
|
1f30c86d4f | ||
|
|
d3c62e63bf | ||
|
|
57df2e15f2 | ||
|
|
4f1b1d68c7 | ||
|
|
9debe4e891 | ||
|
|
7ea2d7883f | ||
|
|
aaa7ebfd84 | ||
|
|
89d5d0f2da | ||
|
|
9426c83109 | ||
|
|
47e64c6f39 | ||
|
|
5803757153 | ||
|
|
44d385cd7f | ||
|
|
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 | ||
|
|
c38b572778 | ||
|
|
d1b9efd4d2 | ||
|
|
0e308eb9ad | ||
|
|
9c9ede0418 | ||
|
|
300f98c29d | ||
|
|
1285c90618 | ||
|
|
bf76f4e006 | ||
|
|
dfaf324a65 | ||
|
|
e296e083ec | ||
|
|
c9cf1f08e3 | ||
|
|
510d76f7c2 | ||
|
|
9fa051be61 | ||
|
|
edd7148aa8 | ||
|
|
d9dd409aa3 | ||
|
|
79aaab93b6 | ||
|
|
df2af0a3e0 | ||
|
|
967c275027 | ||
|
|
1363608552 | ||
|
|
72e082ff2f | ||
|
|
3fa1c4cdec | ||
|
|
d9b73fd81a | ||
|
|
c91f0c8c6d | ||
|
|
91d8f5946f |
@@ -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.zayin.net/api/packages/Zayin/npm/
|
||||
|
||||
jobs:
|
||||
package-release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
revision: ${{ steps.release.outputs.revision }}
|
||||
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The same credential-free gate as CI: build, reproduce, and consume the train.
|
||||
- name: Build, reproduce, and consume the public package train
|
||||
run: pnpm test:package-release --keep package-train
|
||||
|
||||
- name: Describe the proven release train
|
||||
id: release
|
||||
run: |
|
||||
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
manifest="$release/ekklesia-package-train.json"
|
||||
{
|
||||
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
|
||||
# name must not start with a dot: the artifact actions leave hidden files out.
|
||||
- name: Hand the release train on
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
publish:
|
||||
needs: package-release
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# Only for the publication tool itself: nothing is built or installed in this job.
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Receive the release train the gate proved
|
||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
|
||||
# The tool validates the whole train against its manifest, that manifest against the gate's,
|
||||
# its version against the request, and its revision against this commit, before it sends
|
||||
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
|
||||
- name: Publish the exact release train
|
||||
env:
|
||||
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
|
||||
TRAIN_VERSION: ${{ inputs.version }}
|
||||
TRAIN_REVISION: ${{ github.sha }}
|
||||
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||
run: |
|
||||
node scripts/publish-package-train.ts \
|
||||
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
|
||||
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
|
||||
--version "$TRAIN_VERSION" \
|
||||
--revision "$TRAIN_REVISION" \
|
||||
--manifest-sha256 "$MANIFEST_SHA256" \
|
||||
--record publication-record.json \
|
||||
--require-public-read
|
||||
|
||||
- name: Keep the publication record
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train-publication-record
|
||||
path: publication-record.json
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
+12
@@ -1,4 +1,8 @@
|
||||
node_modules/
|
||||
# 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/
|
||||
|
||||
@@ -6,23 +6,61 @@ contents, because restated details drift.
|
||||
|
||||
## What this repository is
|
||||
|
||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
||||
monorepo:
|
||||
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
|
||||
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
|
||||
as a strictly layered pnpm/TypeScript monorepo:
|
||||
|
||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||
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.
|
||||
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
|
||||
imports a feature module, a surface, or an app.
|
||||
- **Modules** are independently versioned product packages. First-party modules live in **their own
|
||||
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
|
||||
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
|
||||
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
|
||||
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
|
||||
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
|
||||
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
|
||||
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
|
||||
Module Database ABI v1 (ADR 0050).
|
||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
|
||||
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
|
||||
|
||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
||||
|
||||
## Product direction lives beside this repository
|
||||
|
||||
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
|
||||
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
|
||||
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
|
||||
this repository to make them available. They are authoritative for product direction: read the
|
||||
relevant stages and sections before designing a change. If this repository's documentation disagrees
|
||||
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
|
||||
silently follow the stale text. Architectural decisions still change only through ADRs (below).
|
||||
|
||||
Rules from those documents that every change here must respect:
|
||||
|
||||
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
|
||||
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
|
||||
and module installation/lifecycle — belong to the platform, and modules consume them.
|
||||
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
|
||||
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
|
||||
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
|
||||
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
|
||||
dark, and system modes. Normal UI colours come only from the shared design tokens
|
||||
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
|
||||
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
|
||||
in product UI. Destructive actions use in-product confirmation proportional to their impact.
|
||||
- **Displays are platform-owned.** A screen starts at the platform's own `/display/setup`, whatever
|
||||
modules are installed, and is paired in **Settings → Displays** (ADR 0064, ADR 0065); the sidebar
|
||||
is navigation only. Signage's own display roster is still a transitional implementation
|
||||
that Stage 1 replaces with the platform's displays. Keep existing paired displays working across
|
||||
any change, and do not build a module installer, theme editor, or display features beyond what a
|
||||
task asks for early.
|
||||
|
||||
## ADRs are authoritative
|
||||
|
||||
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||
@@ -39,6 +77,7 @@ At minimum, read the current versions of:
|
||||
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
||||
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
||||
- `README.md` (current platform status — what is and is not implemented)
|
||||
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
|
||||
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
||||
- `CONTRIBUTING.md`
|
||||
|
||||
@@ -48,8 +87,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
||||
|
||||
## Working conventions
|
||||
|
||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
||||
change branch-protection settings.
|
||||
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
|
||||
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
|
||||
and branch from `origin/main`, never from whatever is checked out.
|
||||
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
|
||||
merge your own PR, and never change branch-protection settings.
|
||||
- Never create or push a release tag. Release tags belong to the human maintainer.
|
||||
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
|
||||
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
|
||||
platform logic into a module to avoid making the correct Core change.
|
||||
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
||||
burying them in an implementation.
|
||||
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
||||
@@ -62,6 +109,13 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
||||
- Never run a database migration automatically (on import, on startup, in a test that isn't
|
||||
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
|
||||
an explicit, operator-invoked action.
|
||||
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
|
||||
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
|
||||
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
|
||||
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
|
||||
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
|
||||
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
|
||||
you filed when you report back.
|
||||
|
||||
## Canonical verification commands
|
||||
|
||||
@@ -71,12 +125,22 @@ ever disagree:
|
||||
- `pnpm verify` — the full code-only gate (format, lint, boundaries, typecheck, tests, module and
|
||||
migration validation, identity schema drift check, build). Docker-free; never touches a live
|
||||
database.
|
||||
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL migration evidence. Requires
|
||||
Docker. Run this for any change touching migration discovery, planning, or execution.
|
||||
- `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
|
||||
against `DATABASE_URL`. Never run `migrations:apply` without the user's explicit intent.
|
||||
- `pnpm api:start` — starts the identity HTTP transport against an already-migrated `DATABASE_URL`.
|
||||
It never applies migrations itself. Never run it without the user's explicit intent.
|
||||
|
||||
## When you're unsure
|
||||
|
||||
|
||||
+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/>.
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/app.d.ts",
|
||||
"import": "./dist/app.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "biome lint .",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
},
|
||||
"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/design-tokens": "workspace:*",
|
||||
"@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"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "app"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AccountPreferences,
|
||||
type AccountStore,
|
||||
AccountStoreError,
|
||||
parseAccountPreferencesUpdate,
|
||||
} from "@ekklesia/core/account";
|
||||
import { CORE_ACCOUNT_SELF_MANAGE } from "@ekklesia/core/authorization";
|
||||
import { type Context, Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
|
||||
export const ACCOUNT_PATH = "/api/v1/account";
|
||||
export const ACCOUNT_PREFERENCES_PATH = `${ACCOUNT_PATH}/preferences`;
|
||||
|
||||
/** `GET /api/v1/account`: the signed-in person's own account, as the account area shows it. */
|
||||
export interface AccountResponse {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly preferences: AccountPreferences;
|
||||
}
|
||||
|
||||
export interface CreateAccountRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly store: AccountStore;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. */
|
||||
function storeFailure(
|
||||
c: Context,
|
||||
error: unknown,
|
||||
fallback: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED",
|
||||
): Response {
|
||||
if (error instanceof AccountStoreError) {
|
||||
if (error.code === "ACCOUNT_NOT_FOUND") {
|
||||
return sanitizedFailure(c, 404, error.code);
|
||||
}
|
||||
if (error.code === "ACCOUNT_PREFERENCES_INVALID") {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
return sanitizedFailure(c, 503, error.code);
|
||||
}
|
||||
return sanitizedFailure(c, 503, fallback);
|
||||
}
|
||||
|
||||
async function readJsonBody(c: Context): Promise<unknown> {
|
||||
try {
|
||||
return (await c.req.json()) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-in person's own account (ADR 0062): who they are signed in as, and their own
|
||||
* preferences.
|
||||
*
|
||||
* ## Self-only by construction
|
||||
*
|
||||
* Neither route accepts a user id — not in the path, not in the query, not in the body. The account
|
||||
* is always the one the **session** names, so there is no request that could read or change anyone
|
||||
* else's, whatever permission the caller holds. A body that names any key besides a known
|
||||
* preference is refused whole.
|
||||
*
|
||||
* ## Authorization
|
||||
*
|
||||
* Both routes require `core.account.self.manage`, whose baseline is `authenticated`: every
|
||||
* signed-in person may manage their own account, and needs no church-management permission to do
|
||||
* so. An anonymous caller and a paired device are refused (`403`). Changing a preference is a
|
||||
* mutation like any other Core mutation: an exact trusted `Origin` and a bounded body are checked
|
||||
* before authorization work begins.
|
||||
*
|
||||
* ## What the browser learns
|
||||
*
|
||||
* Name, email, and preferences — exactly what the account area shows, and nothing else. Not the
|
||||
* account's image URL, verification state, timestamps, or any credential. Responses are per-person
|
||||
* and never cached.
|
||||
*/
|
||||
export function createAccountRoutes(options: CreateAccountRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const requireSelf = requirePermission(options, CORE_ACCOUNT_SELF_MANAGE);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||
|
||||
routes.get(ACCOUNT_PATH, requireSelf, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
// Unreachable through the `authenticated` baseline, which only a person satisfies; checked
|
||||
// again so the account read can never be keyed by anything but a person's own session.
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
try {
|
||||
const account = await options.store.readAccount(principal.userId);
|
||||
if (!account) {
|
||||
return sanitizedFailure(c, 404, "ACCOUNT_NOT_FOUND");
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
preferences: {
|
||||
appearance: account.preferences.appearance,
|
||||
motion: account.preferences.motion,
|
||||
},
|
||||
} satisfies AccountResponse,
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "ACCOUNT_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
routes.patch(
|
||||
ACCOUNT_PREFERENCES_PATH,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireSelf,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
const update = parseAccountPreferencesUpdate(await readJsonBody(c));
|
||||
if (!update) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
try {
|
||||
const preferences = await options.store.updatePreferences(principal.userId, update);
|
||||
return c.json(
|
||||
{
|
||||
appearance: preferences.appearance,
|
||||
motion: preferences.motion,
|
||||
} satisfies AccountPreferences,
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
return storeFailure(c, error, "ACCOUNT_WRITE_FAILED");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleSecretSource,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { AccountStore } from "@ekklesia/core/account";
|
||||
import type {
|
||||
AuthorizationEvaluator,
|
||||
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 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 { createAccountRoutes } from "./account-routes.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDashboardSettingsRoutes } from "./dashboard-settings-routes.js";
|
||||
import { createDeviceRoutes, renewDeviceSessionCookie } 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;
|
||||
/** The complete platform permission registry (Core's own declarations plus every installed
|
||||
* module's) — always the caller-composed registry `startApi` produces via
|
||||
* `prepareStartupModuleInventory`, never a Core-only fallback constructed here. */
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly store: AuthorizationStore;
|
||||
}
|
||||
|
||||
export interface CreateApiAppOptions {
|
||||
readonly authorization: CreateApiAppAuthorizationOptions;
|
||||
/** The started module runtime's published, immutable route snapshot — always the caller-started
|
||||
* runtime's exact `routes` (`startDiscoveredModuleRuntime`, `apps/api/src/server.ts`), mounted in
|
||||
* publication order under `/api/v1/modules/<module-id>`. Required rather than defaulted so a
|
||||
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
||||
* implicit fallback. */
|
||||
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
||||
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
|
||||
* empty, for the same reason as module routes: application composition never guesses what ran. */
|
||||
readonly moduleRealtime: ModuleRealtimeRegistry;
|
||||
/** The same 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 and `devices` is absent: device
|
||||
* support always serves the platform's `/display/setup` page (ADR 0065). Route composition rejects
|
||||
* an inconsistent composition 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;
|
||||
/** Core's persistence for a signed-in person's own account summary and preferences (ADR 0062).
|
||||
* Required like `dashboard`: every installation has accounts. Reachable only through routes that
|
||||
* take the account from the session, never from the request. */
|
||||
readonly account: AccountStore;
|
||||
/**
|
||||
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||
* 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
|
||||
* `requireTrustedOrigin` checks for grant- and module-route-mutation routes — there is no second,
|
||||
* competing origin configuration. */
|
||||
readonly trustedOrigins: readonly string[];
|
||||
}
|
||||
|
||||
function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
||||
const code = error instanceof IdentityRuntimeError ? error.code : "INTERNAL_ERROR";
|
||||
c.header("Cache-Control", "no-store");
|
||||
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
|
||||
* force `Origin`/`Cookie`; `/api/v1/principal` sets `Origin, Cookie` directly; an `OPTIONS`
|
||||
* preflight sets its own) — so the header `cors()` itself leaves behind can still be an invalid
|
||||
* combined "*, Origin" or contain a case-different duplicate. This wraps `middleware` and
|
||||
* canonicalizes whatever `Vary` remains only *after* it has fully finished, using
|
||||
* `canonicalizeVaryHeader` — the same shared parsing/dedup primitive `mergedVaryHeader` (module
|
||||
* responses) builds on, so there is one implementation to keep in sync, not two that could diverge
|
||||
* — but never `mergedVaryHeader` itself: this boundary wraps *every* response, `OPTIONS` preflights
|
||||
* included, and must never inject `Origin`/`Cookie` onto a response that never had them, only
|
||||
* de-duplicate what is already there. Two return shapes have to be handled: a normal request lets
|
||||
* `middleware` call `next()` and mutate `c.res` in place, so the fix-up goes through `c.header()`
|
||||
* (reassigning `c.res` directly triggers Hono's own header-preserving merge and would silently
|
||||
* restore the uncanonicalized value); an `OPTIONS` preflight makes `cors()` return a new `Response`
|
||||
* directly without ever touching `c.res`, so the fix-up mutates that returned response's headers
|
||||
* instead. Neither path reads or clones the response body, so a streaming body is untouched either
|
||||
* way. Status, body, content type, ordinary headers, authorization, trusted-origin behavior, and
|
||||
* route ordering are all unaffected — only the final `Vary` value changes.
|
||||
*/
|
||||
function withCanonicalVary(middleware: MiddlewareHandler): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const result = await middleware(c, next);
|
||||
if (result instanceof Response) {
|
||||
const existingVary = result.headers.get("Vary");
|
||||
if (existingVary !== null) {
|
||||
result.headers.set("Vary", canonicalizeVaryHeader(existingVary));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const existingVary = c.res.headers.get("Vary");
|
||||
if (existingVary !== null) {
|
||||
c.header("Vary", canonicalizeVaryHeader(existingVary));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure Hono application factory: no I/O, no environment reads, no process side effects, and it
|
||||
* never invokes a module route handler while composing routes. Suitable for unit and integration
|
||||
* tests via `app.fetch` directly, with a fully injected identity runtime and authorization
|
||||
* evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. Synchronously
|
||||
* throws `ModuleRouteConfigurationError` (`./module-http-routes.js`) if `moduleRoutes` and
|
||||
* `authorization.registry` are inconsistent — a route naming a permission the registry does not
|
||||
* register, which should never happen since both derive from the same discovered inventory, but is
|
||||
* checked explicitly rather than silently mounting an unenforceable route.
|
||||
*/
|
||||
export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
const { runtime } = options;
|
||||
const allowedOrigins = new Set(options.trustedOrigins);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const corsMiddleware = withCanonicalVary(
|
||||
cors({
|
||||
allowHeaders: ["Content-Type"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
credentials: true,
|
||||
maxAge: 600,
|
||||
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
|
||||
}),
|
||||
);
|
||||
app.use("/api/auth/*", corsMiddleware);
|
||||
app.use("/api/v1/*", corsMiddleware);
|
||||
|
||||
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
|
||||
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
|
||||
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
|
||||
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
|
||||
// policy to the response.
|
||||
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
|
||||
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
|
||||
return next();
|
||||
}
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "NOT_FOUND" }, 404);
|
||||
});
|
||||
|
||||
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
|
||||
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/v1/principal", async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
try {
|
||||
const principal = await runtime.resolvePrincipal(c.req.raw.headers);
|
||||
return c.json(
|
||||
isAuthenticatedPrincipal(principal)
|
||||
? { kind: "authenticated" as const, userId: principal.userId }
|
||||
: { kind: "anonymous" as const },
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
return sanitizedErrorResponse(c, error, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 fixed paths outside `/api/` (ADR 0044, ADR 0057): the
|
||||
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
|
||||
// `/<module-id>`. 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, {
|
||||
modulePages: options.moduleLifecycle.snapshot.discoveredModules
|
||||
.filter((module) => module.entrypoints.ui !== undefined)
|
||||
.map((module) => module.manifest.id),
|
||||
}),
|
||||
);
|
||||
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
store: options.authorization.store,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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 } : {}),
|
||||
...(options.devices
|
||||
? {
|
||||
platformSetup: true,
|
||||
renewDeviceCredential: (c: Context) =>
|
||||
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
// Which Settings destinations the viewer may see (ADR 0061): server-evaluated presentation hints
|
||||
// beside the navigation, never authority. See `./dashboard-settings-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createDashboardSettingsRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
platformOwners: options.authorization.store,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
snapshot: options.moduleLifecycle.snapshot,
|
||||
}),
|
||||
);
|
||||
|
||||
// The signed-in person's own account and preferences (ADR 0062). Self-only by construction: the
|
||||
// account is always the session's, and no request value can name another. See
|
||||
// `./account-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createAccountRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
store: options.account,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
||||
// 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,
|
||||
});
|
||||
|
||||
app.notFound((c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "NOT_FOUND" }, 404);
|
||||
});
|
||||
app.onError((_error, c) => sanitizedErrorResponse(c, _error, 500));
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
|
||||
import type { AuthorizationEvaluator, PermissionRegistry } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||
|
||||
const PRINCIPAL_CONTEXT_KEY = "ekklesiaPrincipal";
|
||||
|
||||
export interface AuthorizationMiddlewareOptions {
|
||||
readonly evaluator: AuthorizationEvaluator;
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
}
|
||||
|
||||
function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from the injected,
|
||||
* trusted, server-side `PermissionRegistry` — in production, the complete platform registry
|
||||
* `startApi` composes from Core's own declarations plus every installed module's, never a
|
||||
* Core-only fallback. A route never supplies its own baseline, and no request input can influence
|
||||
* which baseline is checked. The principal is resolved exclusively
|
||||
* through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal`
|
||||
* already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or
|
||||
* grants. A permission id that is not registered fails closed (403) exactly like a denied
|
||||
* decision — in normal operation this route only ever hard-codes an already-registered constant,
|
||||
* but the check stays live per-request as defense in depth rather than being trusted once at
|
||||
* composition time.
|
||||
*
|
||||
* On success, the resolved `EkklesiaPrincipal` — Core's own narrow `{kind, userId}` contract, not
|
||||
* Better Auth's richer session/user object — is stored on the Hono context so the route handler
|
||||
* can build an actor without resolving the principal a second time. This is deliberately narrower
|
||||
* than the "no generalized session/user object in Hono context" concern ADR 0011 raised about
|
||||
* Better Auth's own documented example: only Core's already-public-safe principal shape is ever
|
||||
* set, never a session token, raw user row, or Better Auth type.
|
||||
*
|
||||
* A genuine infrastructure failure — principal resolution, evaluation, or the underlying
|
||||
* database — is never folded into a 403; it returns a sanitized 503, mirroring
|
||||
* `/api/v1/principal`'s existing behavior for the identical reason: a caller must never see "no
|
||||
* permission" when the true answer is "could not be determined."
|
||||
*/
|
||||
export function requirePermission(
|
||||
options: AuthorizationMiddlewareOptions,
|
||||
permissionId: PermissionId,
|
||||
): MiddlewareHandler {
|
||||
return async (c: Context, next: Next) => {
|
||||
const declaration = options.registry.get(permissionId);
|
||||
if (!declaration) {
|
||||
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
|
||||
let principal: EkklesiaPrincipal;
|
||||
try {
|
||||
principal = await options.runtime.resolvePrincipal(c.req.raw.headers);
|
||||
} catch {
|
||||
return sanitizedDenial(c, 503, "PRINCIPAL_RESOLUTION_FAILED");
|
||||
}
|
||||
|
||||
let decision: { allowed: boolean };
|
||||
try {
|
||||
decision = await options.evaluator.authorize({
|
||||
baseline: declaration.baseline,
|
||||
permission: declaration.id,
|
||||
principal,
|
||||
});
|
||||
} catch {
|
||||
return sanitizedDenial(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
|
||||
}
|
||||
|
||||
if (!decision.allowed) {
|
||||
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
|
||||
c.set(PRINCIPAL_CONTEXT_KEY, principal);
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the principal a preceding `requirePermission` middleware already resolved and verified.
|
||||
* Throws (a route-wiring bug, not a runtime condition) if called on a route this middleware never
|
||||
* protected. */
|
||||
export function getAuthorizedPrincipal(c: Context): EkklesiaPrincipal {
|
||||
const principal = c.get(PRINCIPAL_CONTEXT_KEY) as EkklesiaPrincipal | undefined;
|
||||
if (!principal) {
|
||||
throw new Error("getAuthorizedPrincipal called outside a requirePermission-protected route.");
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires an exact trusted `Origin` header for a cookie-authenticated, state-changing route.
|
||||
* CORS response headers alone are not CSRF protection — they only stop a browser from *reading* a
|
||||
* cross-origin response, not from *sending* the state-changing request in the first place — so
|
||||
* mutation routes need this explicit, server-side check in addition to (not instead of) the
|
||||
* existing CORS policy. A missing or untrusted origin is rejected before the route (or the
|
||||
* permission check) ever runs. Non-browser, non-cookie automation (service accounts/API tokens)
|
||||
* is deliberately out of scope for this slice and has no way to satisfy this check yet.
|
||||
*/
|
||||
export function requireTrustedOrigin(trustedOrigins: ReadonlySet<string>): MiddlewareHandler {
|
||||
return async (c: Context, next: Next) => {
|
||||
const origin = c.req.header("origin");
|
||||
if (!origin || !trustedOrigins.has(origin)) {
|
||||
return sanitizedDenial(c, 403, "UNTRUSTED_ORIGIN");
|
||||
}
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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"
|
||||
| "DATABASE_URL_MISSING"
|
||||
| "HOST_INVALID"
|
||||
| "PORT_INVALID";
|
||||
|
||||
/** Never carries the offending database URL, host, port, or any other caller-supplied value. */
|
||||
export class ApiConfigError extends Error {
|
||||
readonly code: ApiConfigErrorCode;
|
||||
|
||||
constructor(code: ApiConfigErrorCode) {
|
||||
super(apiConfigErrorMessages[code]);
|
||||
this.name = "ApiConfigError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
const apiConfigErrorMessages: Readonly<Record<ApiConfigErrorCode, string>> = Object.freeze({
|
||||
DATABASE_URL_INVALID:
|
||||
"The API database URL must be an absolute postgres:// or postgresql:// URL.",
|
||||
DATABASE_URL_MISSING: "The API database URL is required.",
|
||||
HOST_INVALID: "The API listen address must be a non-empty host.",
|
||||
PORT_INVALID:
|
||||
"The API listen port must be an integer between 0 and 65535 (0 requests an OS-assigned port).",
|
||||
});
|
||||
|
||||
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 {
|
||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||
}
|
||||
|
||||
function validateDatabaseUrl(databaseUrl: unknown): string {
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||
throw new ApiConfigError("DATABASE_URL_MISSING");
|
||||
}
|
||||
let protocol: string;
|
||||
try {
|
||||
protocol = new URL(databaseUrl).protocol;
|
||||
} catch {
|
||||
throw new ApiConfigError("DATABASE_URL_INVALID");
|
||||
}
|
||||
if (protocol !== "postgres:" && protocol !== "postgresql:") {
|
||||
throw new ApiConfigError("DATABASE_URL_INVALID");
|
||||
}
|
||||
return databaseUrl;
|
||||
}
|
||||
|
||||
function validateHost(host: unknown): string {
|
||||
if (host === undefined) {
|
||||
return defaultHost;
|
||||
}
|
||||
if (typeof host !== "string" || host.trim().length === 0) {
|
||||
throw new ApiConfigError("HOST_INVALID");
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function validatePort(port: unknown): number {
|
||||
if (port === undefined) {
|
||||
return defaultPort;
|
||||
}
|
||||
const numericPort = typeof port === "string" ? Number(port) : port;
|
||||
if (
|
||||
typeof numericPort !== "number" ||
|
||||
!Number.isInteger(numericPort) ||
|
||||
numericPort < 0 ||
|
||||
numericPort > 65_535
|
||||
) {
|
||||
throw new ApiConfigError("PORT_INVALID");
|
||||
}
|
||||
return numericPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates explicit, typed API runtime configuration, delegating identity fields to Core's
|
||||
* existing `createIdentityConfig` rather than defining a competing identity configuration
|
||||
* contract. Performs no environment reads, network calls, or database access.
|
||||
*/
|
||||
export function createApiRuntimeConfig(
|
||||
input: ApiRuntimeConfigInput,
|
||||
options: CreateApiRuntimeConfigOptions = {},
|
||||
): ApiRuntimeConfig {
|
||||
const databaseUrl = validateDatabaseUrl(input.databaseUrl);
|
||||
const host = validateHost(input.host);
|
||||
const port = validatePort(input.port);
|
||||
const identity = createIdentityConfig(
|
||||
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);
|
||||
|
||||
// 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,127 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
|
||||
/**
|
||||
* The three immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044):
|
||||
* the page, its application script, and the small pre-paint appearance script the page loads in
|
||||
* `<head>` (ADR 0062).
|
||||
*
|
||||
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||
* 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;
|
||||
readonly appearanceScript: string;
|
||||
readonly appearanceScriptEtag: 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,
|
||||
appearanceScript: string,
|
||||
): DashboardHostAssets {
|
||||
if (
|
||||
html.trim().length === 0 ||
|
||||
script.trim().length === 0 ||
|
||||
appearanceScript.trim().length === 0
|
||||
) {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
appearanceScript,
|
||||
appearanceScriptEtag: strongEtag(appearanceScript),
|
||||
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 three surface-declared files, and renders the API origin into the page. A missing,
|
||||
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
|
||||
* `/` 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");
|
||||
const appearanceScript = await readFile(new URL(dashboardHostAssetUrls.appearance), "utf8");
|
||||
return createDashboardHostAssets(
|
||||
renderDashboardPage(html, apiOrigin),
|
||||
script,
|
||||
appearanceScript,
|
||||
);
|
||||
} catch {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { moduleIdSchema } from "@ekklesia/contracts";
|
||||
import { isReservedModuleId } from "@ekklesia/core/modules";
|
||||
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
||||
import { Hono } from "hono";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
|
||||
/**
|
||||
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
|
||||
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
|
||||
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
|
||||
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
|
||||
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
|
||||
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
|
||||
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
|
||||
* lands on `/` with a query (ADR 0038).
|
||||
*/
|
||||
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||
/** The pre-paint appearance script (ADR 0062), beside the application script. */
|
||||
export const DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH = "/assets/ekklesia-dashboard/appearance.js";
|
||||
|
||||
/**
|
||||
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||
* 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, input.appearanceScript);
|
||||
if (
|
||||
snapshot.htmlEtag !== input.htmlEtag ||
|
||||
snapshot.scriptEtag !== input.scriptEtag ||
|
||||
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export interface DashboardHostRouteOptions {
|
||||
/**
|
||||
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
|
||||
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
|
||||
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
|
||||
*/
|
||||
readonly modulePages: readonly string[];
|
||||
}
|
||||
|
||||
/** Each module page must be a real module id that names no platform namespace, exactly once.
|
||||
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
|
||||
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const moduleId of modulePages) {
|
||||
if (
|
||||
!moduleIdSchema.safeParse(moduleId).success ||
|
||||
isReservedModuleId(moduleId) ||
|
||||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
|
||||
seen.has(moduleId)
|
||||
) {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
seen.add(moduleId);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||
* default same-origin shape.
|
||||
*
|
||||
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
|
||||
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
|
||||
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
|
||||
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
|
||||
* server change later) — always with the **same** bytes and the same validator, because the path
|
||||
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
|
||||
* bookmarked deep link stay where it was.
|
||||
*
|
||||
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
|
||||
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
|
||||
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the
|
||||
* application's ordinary sanitized `404`. Like the display host's code, these bytes grant no
|
||||
* authority: the page renders the sign-in form until `/api/v1/principal` says otherwise.
|
||||
*/
|
||||
export function createDashboardHostRoutes(
|
||||
assets: DashboardHostAssets,
|
||||
options: DashboardHostRouteOptions,
|
||||
): Hono {
|
||||
const snapshot = snapshotAssets(assets);
|
||||
const modulePages = validatedModulePages(options.modulePages);
|
||||
const routes = new Hono();
|
||||
|
||||
const page = (ifNoneMatch: string | undefined): Response =>
|
||||
fixedAsset(
|
||||
ifNoneMatch,
|
||||
snapshot.html,
|
||||
snapshot.htmlEtag,
|
||||
"text/html; charset=utf-8",
|
||||
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||
);
|
||||
|
||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
|
||||
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
|
||||
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
|
||||
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
|
||||
}
|
||||
|
||||
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
snapshot.script,
|
||||
snapshot.scriptEtag,
|
||||
"text/javascript; charset=utf-8",
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
// Same-origin, revalidated like the application script, and carrying nothing but code: the
|
||||
// person's cached choice lives in their browser and is read by this script, never served by it.
|
||||
routes.get(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, (c) =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
snapshot.appearanceScript,
|
||||
snapshot.appearanceScriptEtag,
|
||||
"text/javascript; charset=utf-8",
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
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,
|
||||
type MountableModuleDescription,
|
||||
} 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;
|
||||
/**
|
||||
* What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon the
|
||||
* Dashboard draws it with (`null` for the generic one) and its primary pages, in declared order —
|
||||
* empty for a single-page module, which every manifest v8 module is. The module's identity, never
|
||||
* its placement: where it sits in the sidebar stays the administrator's (ADR 0027).
|
||||
*/
|
||||
readonly navigation: {
|
||||
readonly icon: string | null;
|
||||
readonly pages: readonly {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly path: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
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: MountableModuleDescription): DashboardNavigationModuleView => ({
|
||||
module: entry.descriptor,
|
||||
name: entry.name,
|
||||
navigation: {
|
||||
icon: entry.navigation.icon ?? null,
|
||||
pages: entry.navigation.pages.map((page) => ({
|
||||
id: page.id,
|
||||
label: page.label,
|
||||
path: page.path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationPlatformOwnerPort,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { ModuleLifecycleSnapshot } from "@ekklesia/core/modules";
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "@ekklesia/shell/dashboard-routes";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
|
||||
export const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
|
||||
|
||||
/** One module's settings destination: its id (the `/settings/<module-id>` segment), its display
|
||||
* name, and whether it is running. A disabled or blocked module is listed so its address can say it
|
||||
* is unavailable, and flagged so the Dashboard never offers it in navigation. */
|
||||
export interface DashboardSettingsModuleView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly available: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardSettingsResponse {
|
||||
/** The platform sections this viewer may see, in navigation order. */
|
||||
readonly sections: readonly SettingsSection[];
|
||||
/** Modules whose settings this viewer may see, by name. */
|
||||
readonly modules: readonly DashboardSettingsModuleView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may see each platform section (ADR 0061).
|
||||
*
|
||||
* A section whose administration already has a Core permission uses it, so it is delegable today.
|
||||
* General, Branding, and System have no permission yet — nothing in them exists to authorize — so
|
||||
* they are shown only to the **platform owner**, the one principal who already holds every
|
||||
* permission; each gains its own delegable permission in the work that builds it.
|
||||
*/
|
||||
const SECTION_ACCESS: Readonly<Record<SettingsSection, PermissionId | "platform-owner">> =
|
||||
Object.freeze({
|
||||
branding: "platform-owner",
|
||||
// Finding, installing, turning on and off, and updating modules: deciding which modules run.
|
||||
discover: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
displays: CORE_DEVICES_MANAGE,
|
||||
general: "platform-owner",
|
||||
system: "platform-owner",
|
||||
});
|
||||
|
||||
export interface CreateDashboardSettingsRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The boot snapshot: every module this process discovered, and which it started. A module's
|
||||
* settings exist when its manifest declares configuration (ADR 0029). */
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dashboard's Settings navigation, evaluated for the viewer.
|
||||
*
|
||||
* Like the module permission hints in the navigation (ADR 0026), these are **presentation hints,
|
||||
* not authority**: they decide which Settings destinations the Dashboard offers, so nobody is shown
|
||||
* administration they cannot carry out (DESIGN.md §9.2). Every route behind a section keeps its own
|
||||
* enforcement, unchanged. Each answer goes through Core's one evaluator — baselines, grants, expiry,
|
||||
* and the owner bypass behave exactly as on a real request — and an evaluation failure is a `503`,
|
||||
* never an empty answer that would look like a permissions problem.
|
||||
*
|
||||
* Reading requires `core.modules.ui.read`, the same permission the navigation it sits beside
|
||||
* requires: any signed-in person may ask, and most will be told there is nothing for them.
|
||||
*/
|
||||
export function createDashboardSettingsRoutes(options: CreateDashboardSettingsRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const requireRead = requirePermission(options, CORE_MODULES_UI_READ);
|
||||
const started = new Set(options.snapshot.startedModuleIds);
|
||||
const modulesWithSettings = options.snapshot.discoveredModules
|
||||
.filter((module) => module.manifest.configuration.length > 0)
|
||||
.map((module) => ({ id: module.manifest.id, name: module.manifest.name }))
|
||||
.sort((left, right) => compareText(left.name, right.name) || compareText(left.id, right.id));
|
||||
|
||||
const holds = async (
|
||||
principal: EkklesiaPrincipal,
|
||||
permission: PermissionId,
|
||||
): Promise<boolean> => {
|
||||
const declaration = options.registry.get(permission);
|
||||
if (!declaration) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
await options.evaluator.authorize({
|
||||
baseline: declaration.baseline,
|
||||
permission,
|
||||
principal,
|
||||
})
|
||||
).allowed;
|
||||
};
|
||||
|
||||
routes.get(DASHBOARD_SETTINGS_PATH, requireRead, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
try {
|
||||
let owner: boolean | undefined;
|
||||
const isOwner = async (): Promise<boolean> => {
|
||||
owner ??=
|
||||
principal.kind === "authenticated" &&
|
||||
(await options.platformOwners.isPlatformOwner(principal.userId));
|
||||
return owner;
|
||||
};
|
||||
const sections: SettingsSection[] = [];
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
const access = SECTION_ACCESS[section];
|
||||
if (access === "platform-owner" ? await isOwner() : await holds(principal, access)) {
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
const modules = (await holds(principal, CORE_MODULES_CONFIGURATION_MANAGE))
|
||||
? modulesWithSettings.map((module) => ({ ...module, available: started.has(module.id) }))
|
||||
: [];
|
||||
return c.json({ modules, sections } satisfies DashboardSettingsResponse, 200);
|
||||
} catch {
|
||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
|
||||
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 {
|
||||
createModuleConfigurationView,
|
||||
createModuleDatabaseExecutor,
|
||||
createModuleDatabaseView,
|
||||
createModuleLifecycleView,
|
||||
type ModuleConfigurationDrizzleDatabase,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleDrizzleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly account: AccountDrizzleDatabase;
|
||||
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 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.
|
||||
*/
|
||||
export function createApiDatabase(
|
||||
databaseUrl: string,
|
||||
options: PostgresPoolOptions = {},
|
||||
): ApiDatabaseHandle {
|
||||
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
||||
|
||||
return Object.freeze({
|
||||
account: createAccountDrizzleView(pool),
|
||||
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,545 @@
|
||||
import { type PermissionId, 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";
|
||||
import { moduleDisplayPageUrl } from "./module-display-path.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: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||
name: string,
|
||||
value: string,
|
||||
maxAgeSeconds: number,
|
||||
): void {
|
||||
setCookie(c, name, value, {
|
||||
httpOnly: true,
|
||||
maxAge: maxAgeSeconds,
|
||||
path: "/",
|
||||
sameSite: "Strict",
|
||||
secure: options.secureCookies,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
|
||||
*
|
||||
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
|
||||
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
|
||||
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
|
||||
*/
|
||||
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
|
||||
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
|
||||
if (credential === undefined || credential.length === 0) {
|
||||
return;
|
||||
}
|
||||
writeDeviceCookie(
|
||||
c,
|
||||
{ secureCookies },
|
||||
DEVICE_SESSION_COOKIE,
|
||||
credential,
|
||||
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||
);
|
||||
}
|
||||
|
||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
const routes = new HonoApp();
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
/** A running module display by id, with its declared display permission, or `undefined`. */
|
||||
const findDisplay = (
|
||||
moduleId: string,
|
||||
): { readonly moduleId: string; readonly permissionId: PermissionId } | undefined => {
|
||||
const display = options.moduleUi
|
||||
.listDisplays()
|
||||
.find((candidate) => candidate.moduleId === moduleId);
|
||||
return display === undefined
|
||||
? undefined
|
||||
: { moduleId: display.moduleId, permissionId: display.displayPermission };
|
||||
};
|
||||
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.
|
||||
*
|
||||
* Two kinds of screen ask (ADR 0065). One on the platform's own setup page (`/display/setup`)
|
||||
* sends `{}`: it becomes a platform display with no module, and the approving administrator may
|
||||
* choose which installed display module it shows. One on a module's display page sends
|
||||
* `{ "moduleId": … }`, as released displays always have.
|
||||
*
|
||||
* The module, when there is one, is not taken at its word: it 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 — any other key is ignored, the request records the permission the host derived,
|
||||
* or none, and approval grants exactly that.
|
||||
*/
|
||||
routes.post(
|
||||
`${DEVICE_PAIRING_PREFIX}/requests`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
async (c: Context) => {
|
||||
const body = await readJsonObject(c);
|
||||
if (body === undefined) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
let display: { readonly moduleId: string; readonly permissionId: PermissionId } | undefined;
|
||||
if ("moduleId" in body) {
|
||||
const moduleId = readString(body, "moduleId", 128);
|
||||
if (moduleId === undefined) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const found = findDisplay(moduleId);
|
||||
if (!found) {
|
||||
// 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");
|
||||
}
|
||||
display = found;
|
||||
}
|
||||
|
||||
try {
|
||||
const at = now();
|
||||
const created = await options.devices.createPairingRequest({
|
||||
...(display === undefined ? {} : { display }),
|
||||
now: at,
|
||||
});
|
||||
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(),
|
||||
// `null` for a platform display no module has been chosen for yet (ADR 0065).
|
||||
moduleId: found.moduleId ?? null,
|
||||
permissionId: found.permissionId ?? null,
|
||||
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.
|
||||
*
|
||||
* `moduleId`, optional, is the installed display module the administrator chose for a platform
|
||||
* display to show (ADR 0065). It must be a running display, and the permission granted is that
|
||||
* module's declared display permission, never one the request names. A screen that asked from a
|
||||
* module's own display page already names its module; a different `moduleId` is refused, so its
|
||||
* permission can never be swapped for another module's.
|
||||
*/
|
||||
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 (
|
||||
body === undefined ||
|
||||
code === undefined ||
|
||||
!/^[0-9]{6}$/u.test(code) ||
|
||||
deviceName === undefined
|
||||
) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
let display: { readonly moduleId: string; readonly permissionId: PermissionId } | undefined;
|
||||
if ("moduleId" in body) {
|
||||
const moduleId = readString(body, "moduleId", 128);
|
||||
display = moduleId === undefined ? undefined : findDisplay(moduleId);
|
||||
if (display === 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,
|
||||
...(display === undefined ? {} : { display }),
|
||||
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 ?? null, permissionId: approved.permissionId ?? null },
|
||||
200,
|
||||
);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The modules a display can be set to show (ADR 0065): every running module with a display, by
|
||||
* name, with the display permission showing it requires, for Settings → Displays' "Show on this
|
||||
* screen" choice and to name what each paired display shows.
|
||||
*
|
||||
* Administrative, like the device list beside it: `core.devices.manage`, and never anonymous, so
|
||||
* it is not a way to enumerate what is installed. Setting up a screen needs none of it — that is
|
||||
* the platform's own `/display/setup`, whatever is installed. An empty list is a real answer: no
|
||||
* installed module uses displays yet, and a display paired now simply shows nothing until one is.
|
||||
*/
|
||||
routes.get(`${DEVICES_PREFIX}/display-modules`, requireManage, (c: Context) => {
|
||||
noStore(c);
|
||||
return c.json(
|
||||
{
|
||||
modules: options.moduleUi.listDisplays().map((display) => ({
|
||||
moduleId: display.moduleId,
|
||||
name: display.name,
|
||||
permissionId: display.displayPermission,
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A paired display asking about itself (ADR 0065): its name, and the module displays it may show
|
||||
* now. The platform's setup page asks this to know whether to open a module's display, to show
|
||||
* "paired as Foyer TV" while nothing is assigned, and to notice revocation. Asking also keeps an
|
||||
* idle display's credential alive, exactly like a module display's descriptor (ADR 0056).
|
||||
*
|
||||
* Only a device is answered. A person — even an administrator who opened the page on their own
|
||||
* laptop — and an unpaired screen are refused, so this reveals nothing to anyone else.
|
||||
*/
|
||||
routes.get(`${DEVICE_PAIRING_PREFIX}/self`, async (c: Context) => {
|
||||
noStore(c);
|
||||
c.header("Vary", "Cookie");
|
||||
let principal: Awaited<ReturnType<typeof options.runtime.resolvePrincipal>>;
|
||||
try {
|
||||
principal = await options.runtime.resolvePrincipal(c.req.raw.headers);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||
}
|
||||
if (principal.kind !== "device") {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
try {
|
||||
const self = await options.devices.describeDevice(principal.deviceId);
|
||||
if (!self) {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
renewDeviceSessionCookie(c, options.secureCookies);
|
||||
const held = new Set(self.permissions);
|
||||
return c.json(
|
||||
{
|
||||
displays: options.moduleUi
|
||||
.listDisplays()
|
||||
.filter((display) => held.has(display.displayPermission))
|
||||
.map((display) => ({
|
||||
moduleId: display.moduleId,
|
||||
url: moduleDisplayPageUrl(display.moduleId),
|
||||
})),
|
||||
name: self.name,
|
||||
},
|
||||
200,
|
||||
);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Changes what a paired display shows (ADR 0065): `{ "moduleId": "<id>" }` for a running display
|
||||
* module, or `{ "moduleId": null }` for nothing. The permission granted is that module's declared
|
||||
* display permission, never one the request names. It is the same display — the same identity,
|
||||
* name, and credential — before and after; the screen notices on its next check and opens the new
|
||||
* module's page, or the platform's setup page, without being paired again.
|
||||
*/
|
||||
routes.put(
|
||||
`${DEVICES_PREFIX}/:deviceId/display`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c: Context) => {
|
||||
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
|
||||
const body = await readJsonObject(c);
|
||||
if (!parsed.success || body === undefined || !("moduleId" in body)) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
let permissionId: PermissionId | undefined;
|
||||
if (body.moduleId !== null) {
|
||||
const moduleId = readString(body, "moduleId", 128);
|
||||
const display = moduleId === undefined ? undefined : findDisplay(moduleId);
|
||||
if (display === undefined) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
permissionId = display.permissionId;
|
||||
}
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (principal.kind !== "authenticated") {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
try {
|
||||
const changed = await options.devices.setDisplayTarget({
|
||||
changedBy: principal.userId,
|
||||
deviceId: parsed.data,
|
||||
now: now(),
|
||||
permissionId,
|
||||
});
|
||||
if (!changed) {
|
||||
// Unknown and revoked alike: neither is a display whose content can change.
|
||||
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||
}
|
||||
noStore(c);
|
||||
return c.json({ moduleId: body.moduleId === null ? null : body.moduleId }, 200);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
||||
try {
|
||||
const devices = await options.devices.listDevices();
|
||||
noStore(c);
|
||||
return c.json(
|
||||
{
|
||||
devices: devices.map((device) => ({
|
||||
createdAt: device.createdAt.toISOString(),
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
permissions: device.permissions,
|
||||
revoked: device.revokedAt !== undefined,
|
||||
...(device.lastSeenAt === undefined
|
||||
? {}
|
||||
: { lastSeenAt: device.lastSeenAt.toISOString() }),
|
||||
...(device.revokedAt === undefined
|
||||
? {}
|
||||
: { revokedAt: device.revokedAt.toISOString() }),
|
||||
})),
|
||||
},
|
||||
200,
|
||||
);
|
||||
} catch {
|
||||
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Revocation, which is a server-side fact rather than a request that the remote browser discard
|
||||
* anything. The device's own copy of its credential keeps existing and stops working.
|
||||
*/
|
||||
routes.post(
|
||||
`${DEVICES_PREFIX}/:deviceId/revoke`,
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManage,
|
||||
async (c: Context) => {
|
||||
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
|
||||
if (!parsed.success) {
|
||||
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||
}
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (principal.kind !== "authenticated") {
|
||||
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
try {
|
||||
await options.devices.revokeDevice({
|
||||
deviceId: parsed.data,
|
||||
now: now(),
|
||||
revokedBy: principal.userId,
|
||||
});
|
||||
} catch {
|
||||
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||
}
|
||||
noStore(c);
|
||||
return c.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||
|
||||
/**
|
||||
* The three immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||
*
|
||||
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
|
||||
* genuinely immutable value that every response can reuse safely.
|
||||
*/
|
||||
export interface DisplayHostAssets {
|
||||
readonly html: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
|
||||
* at a path whose directory is its scope so it controls display pages and nothing else. */
|
||||
readonly serviceWorker: string;
|
||||
readonly serviceWorkerEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
* location, and machine path are intentionally not retained or surfaced. */
|
||||
export class DisplayHostAssetError extends Error {
|
||||
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
|
||||
|
||||
constructor() {
|
||||
super("The display host browser assets are unavailable.");
|
||||
this.name = "DisplayHostAssetError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures one immutable, internally consistent display-host asset snapshot.
|
||||
*
|
||||
* The strong validator is always derived from the exact script text in the same snapshot; callers
|
||||
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||
* failed host build rather than a valid page that can never mount anything.
|
||||
*/
|
||||
export function createDisplayHostAssets(
|
||||
html: string,
|
||||
script: string,
|
||||
serviceWorker: string,
|
||||
): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
serviceWorker,
|
||||
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
|
||||
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
try {
|
||||
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
|
||||
// rejected sibling promise is left running after this function has already failed.
|
||||
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
||||
return createDisplayHostAssets(html, script, serviceWorker);
|
||||
} catch {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { EkklesiaUserId, GrantId, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
createUserActor,
|
||||
isAuthenticatedPrincipal,
|
||||
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";
|
||||
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";
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
interface ParsedCreateGrantBody {
|
||||
readonly expiresAt?: Date;
|
||||
readonly permission: PermissionId;
|
||||
readonly subjectUserId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "expiresAt"]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
const body = raw as Record<string, unknown>;
|
||||
if (Object.keys(body).some((key) => !CREATE_GRANT_ALLOWED_KEYS.has(key))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const subjectUserId = safeParseEkklesiaUserId(body.subjectUserId);
|
||||
if (!subjectUserId.success) {
|
||||
return undefined;
|
||||
}
|
||||
const permission = safeParsePermissionId(body.permission);
|
||||
if (!permission.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let expiresAt: Date | undefined;
|
||||
if (body.expiresAt !== undefined) {
|
||||
expiresAt = parseCanonicalFutureInstant(body.expiresAt, now);
|
||||
if (expiresAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
permission: permission.data,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function serializeGrant(grant: GrantRecord) {
|
||||
return {
|
||||
createdAt: grant.createdAt.toISOString(),
|
||||
expiresAt: grant.expiresAt ? grant.expiresAt.toISOString() : null,
|
||||
id: grant.id,
|
||||
permission: grant.permission,
|
||||
revokedAt: grant.revokedAt ? grant.revokedAt.toISOString() : null,
|
||||
scope: grant.scope ?? null,
|
||||
sourceKind: grant.sourceKind,
|
||||
userId: grant.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v1 explicit-grant management API: list, create, and revoke *explicit* grants only. Every
|
||||
* route requires `core.authorization.grants.manage`; a platform owner passes through the
|
||||
* evaluator's existing owner bypass without ever needing an explicit grant of this permission
|
||||
* themselves — that bypass is exactly how a platform owner deliberately delegates grant
|
||||
* administration to another account by granting them this one permission.
|
||||
*
|
||||
* Relationship-derived grants are never reachable through this API in either direction: creation
|
||||
* always hard-codes `sourceKind: "explicit"` server-side (the request body cannot supply a
|
||||
* `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");
|
||||
// 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 page = await options.store.listGrantsForUser(query.subjectUserId, {
|
||||
...(query.after === undefined ? {} : { after: query.after }),
|
||||
limit: query.limit,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
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",
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
await options.store.revokeGrant({
|
||||
actor: createUserActor(principal.userId),
|
||||
grantId: grantId.data satisfies GrantId,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
return c.json({ success: true }, 200);
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -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,23 @@
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new screen starts pairing as a platform display, before any module is chosen for it (ADR
|
||||
* 0065). `setup` is a reserved module id (`RESERVED_DISPLAY_HOST_NAMES`), so no module's display can
|
||||
* take this address.
|
||||
*/
|
||||
export const DISPLAY_SETUP_PAGE_URL = `${MODULE_DISPLAY_PAGE_PREFIX}/setup`;
|
||||
@@ -0,0 +1,330 @@
|
||||
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 {
|
||||
DISPLAY_SETUP_PAGE_URL,
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
} from "./module-display-path.js";
|
||||
|
||||
import {
|
||||
DISPLAY_SETUP_PAGE_URL,
|
||||
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 and the deployment has no
|
||||
* device support. Production reads the exact surface-owned files before binding and supplies their
|
||||
* frozen contents here. */
|
||||
readonly assets?: DisplayHostAssets;
|
||||
/**
|
||||
* Serve the platform's own setup page, `/display/setup`, where a new screen pairs as a platform
|
||||
* display before any module is chosen for it (ADR 0065). Composed whenever the deployment has
|
||||
* device support, whatever is installed — including no module with a display at all.
|
||||
*/
|
||||
readonly platformSetup?: boolean;
|
||||
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
|
||||
* deployment has device support; called only for a request that resolved to a device. */
|
||||
readonly renewDeviceCredential?: (c: Context) => void;
|
||||
}
|
||||
|
||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||
* 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);
|
||||
}
|
||||
// Every screen asks for this on start, on reconnect, and periodically while running, so it
|
||||
// is where an in-use credential's browser expiry is pushed forward.
|
||||
if (principal.kind === "device") {
|
||||
options.renewDeviceCredential?.(c);
|
||||
}
|
||||
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||
} 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 && options.platformSetup !== true) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The platform's setup page (ADR 0065): the same inert, module-agnostic page bytes as every module
|
||||
* display, so it carries nothing. The browser reads `setup` from its own address and asks to be
|
||||
* paired with no module; what it may show once paired comes from the device's own permissions,
|
||||
* through `/api/v1/device-pairing/self`. `setup` is a reserved module id, so no module's display
|
||||
* can take this address.
|
||||
*/
|
||||
if (options.platformSetup === true) {
|
||||
app.get(DISPLAY_SETUP_PAGE_URL, () => {
|
||||
return new Response(assets.html, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const display of displays) {
|
||||
mountDisplay(app, { ...options, assets }, display);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
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,
|
||||
getAuthorizedPrincipal,
|
||||
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"]);
|
||||
const STRIPPED_CORS_RESPONSE_HEADERS = [
|
||||
"access-control-allow-credentials",
|
||||
"access-control-allow-headers",
|
||||
"access-control-allow-methods",
|
||||
"access-control-allow-origin",
|
||||
"access-control-expose-headers",
|
||||
"access-control-max-age",
|
||||
];
|
||||
|
||||
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
/** The same frozen services object the started module runtime's `start`/`stop` hooks already
|
||||
* 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: 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
|
||||
* detail — a forged or inconsistent route snapshot is a startup-time composition bug, not
|
||||
* something to describe in detail to whatever eventually logs it. */
|
||||
export class ModuleRouteConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("A published module HTTP route references a permission that is not registered.");
|
||||
this.name = "ModuleRouteConfigurationError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const base = moduleApiBaseUrl(moduleId);
|
||||
return routePath === "/" ? base : `${base}${routePath}`;
|
||||
}
|
||||
|
||||
function sanitizedModuleHandlerFailure(c: Context): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "MODULE_HANDLER_FAILED" }, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one shared `Vary` parsing/canonicalization primitive: splits and trims comma-separated
|
||||
* tokens and de-duplicates them case-insensitively — `Vary` lists header *names*, which are
|
||||
* themselves case-insensitive, so a module's own `origin` must collapse with a later `Origin`
|
||||
* rather than appear as a separate, redundant entry — while preserving first-seen casing and
|
||||
* relative order. Never returns early on `*`; callers decide what a `*` token means for their own
|
||||
* policy (see `mergedVaryHeader` and `canonicalizeVaryHeader` below, the two callers that build on
|
||||
* this instead of re-implementing parsing/dedup independently).
|
||||
*/
|
||||
function parseVaryTokens(existingVary: string | null): Map<string, string> {
|
||||
const canonicalByLowercase = new Map<string, string>();
|
||||
for (const token of (existingVary ?? "").split(",")) {
|
||||
const trimmed = token.trim();
|
||||
if (trimmed.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const key = trimmed.toLowerCase();
|
||||
if (!canonicalByLowercase.has(key)) {
|
||||
canonicalByLowercase.set(key, trimmed);
|
||||
}
|
||||
}
|
||||
return canonicalByLowercase;
|
||||
}
|
||||
|
||||
/** A literal `*` (RFC 9110 §12.5.5) must never be combined with any other value; since `*` already
|
||||
* means "may vary on anything," a strictly broader signal than any explicit list, its presence
|
||||
* collapses the result to `*` alone rather than producing an invalid combination. */
|
||||
function serializeVaryTokens(tokens: ReadonlyMap<string, string>): string {
|
||||
if (tokens.has("*")) {
|
||||
return "*";
|
||||
}
|
||||
return [...tokens.values()].join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Used only for a module's own response: always emits the canonical `Origin`/`Cookie` casing
|
||||
* regardless of how (or whether) a module already listed them, since every module response is
|
||||
* forced to vary on both (see `sanitizeModuleResponse`) — this is a deliberate product policy for
|
||||
* module responses specifically, not a generic property every response through the platform's CORS
|
||||
* boundary should have forced onto it (an `OPTIONS` preflight, for one, has no `Cookie` concept at
|
||||
* all).
|
||||
*/
|
||||
export function mergedVaryHeader(existingVary: string | null): string {
|
||||
const tokens = parseVaryTokens(existingVary);
|
||||
if (tokens.has("*")) {
|
||||
return "*";
|
||||
}
|
||||
tokens.set("origin", "Origin");
|
||||
tokens.set("cookie", "Cookie");
|
||||
return serializeVaryTokens(tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used at the app-level CORS boundary (`apps/api/src/app.ts`), which wraps every response —
|
||||
* `OPTIONS` preflights and Core's own routes included, not only module ones — after Hono's own
|
||||
* `cors()` has already appended its own `Vary: Origin`. Unlike `mergedVaryHeader`, this never
|
||||
* injects a token that was not already present; it only de-duplicates and collapses `*`, since a
|
||||
* generic boundary must not assume every response wants `Origin`/`Cookie` forced onto it.
|
||||
*/
|
||||
export function canonicalizeVaryHeader(existingVary: string): string {
|
||||
return serializeVaryTokens(parseVaryTokens(existingVary));
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform owns CORS and caching for every module route: strips any CORS-control header a
|
||||
* handler set (defense in depth — the outer `cors()` middleware already computes the authoritative
|
||||
* values for the actual response) and forces `Cache-Control: no-store` plus a `Vary` that always
|
||||
* includes `Origin` and `Cookie`, since even a public-baseline handler receives an authorized
|
||||
* principal and may personalize its response. A separate cache-policy contract is future work;
|
||||
* this is the conservative default until then. A module's own legitimate `Vary` values (if any)
|
||||
* are preserved alongside the forced ones. Never reads or buffers `response.body` — it is passed
|
||||
* through to the new `Response` unread, so a streaming body is neither consumed nor blocked here.
|
||||
*/
|
||||
function sanitizeModuleResponse(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
for (const headerName of STRIPPED_CORS_RESPONSE_HEADERS) {
|
||||
headers.delete(headerName);
|
||||
}
|
||||
headers.set("Cache-Control", "no-store");
|
||||
headers.set("Vary", mergedVaryHeader(headers.get("Vary")));
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts `StartedModuleRuntime`'s published, immutable route snapshot into `app`, in publication
|
||||
* order — never sorted or mutated, and never invoking a handler while mounting. Every route is
|
||||
* protected by the exact same `requirePermission` middleware Core's own routes use, with
|
||||
* `route.permission` as the only permission id ever consulted and its baseline coming exclusively
|
||||
* from `options.registry`; a state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`) additionally
|
||||
* requires `requireTrustedOrigin` before authorization ever runs. `GET` inherits Hono's own
|
||||
* 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 `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).
|
||||
*
|
||||
* Because the routes and registry both derive from the same discovered inventory
|
||||
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
||||
* registered; this loop still checks that invariant explicitly and throws
|
||||
* `ModuleRouteConfigurationError` before mounting anything further if it ever does not hold, so a
|
||||
* forged or hand-built route snapshot fails app construction instead of silently mounting an
|
||||
* unenforceable route. `requirePermission`'s own per-request registry lookup remains the live,
|
||||
* defense-in-depth enforcement regardless.
|
||||
*/
|
||||
export function mountModuleHttpRoutes(
|
||||
app: Hono,
|
||||
routes: readonly RegisteredModuleHttpRoute[],
|
||||
options: ModuleHttpRoutesOptions,
|
||||
): void {
|
||||
const middlewareOptions: AuthorizationMiddlewareOptions = {
|
||||
evaluator: options.evaluator,
|
||||
registry: options.registry,
|
||||
runtime: options.runtime,
|
||||
};
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
|
||||
for (const route of routes) {
|
||||
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 = 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),
|
||||
realtime: options.realtime.publisherFor(route.moduleId),
|
||||
services,
|
||||
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (
|
||||
route.handler as (
|
||||
request: Request,
|
||||
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
) => PromiseLike<Response> | Response
|
||||
)(c.req.raw, context);
|
||||
} catch {
|
||||
return sanitizedModuleHandlerFailure(c);
|
||||
}
|
||||
if (!(response instanceof Response)) {
|
||||
return sanitizedModuleHandlerFailure(c);
|
||||
}
|
||||
return sanitizeModuleResponse(response);
|
||||
};
|
||||
|
||||
// Hono's `on()` overloads resolve by literal call arity, not by the runtime length of a spread
|
||||
// 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,
|
||||
routeBodyLimit,
|
||||
requireRoutePermission,
|
||||
finalHandler,
|
||||
);
|
||||
} else {
|
||||
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,305 @@
|
||||
import type {
|
||||
EkklesiaPrincipal,
|
||||
ModuleNavigationDeclaration,
|
||||
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 module's own declared icon and primary pages (manifest v9, ADR 0063); empty for one that
|
||||
* declares none. Presentation identity like `name`, and kept beside the descriptor for the same
|
||||
* reason. */
|
||||
readonly navigation: ModuleNavigationDeclaration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
navigation: published.navigation,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { PermissionRegistry } from "@ekklesia/core/authorization";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type DiscoveredModule,
|
||||
resolveModulesDirectory,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||
|
||||
/** Never carries the underlying package.json path, filesystem error, module directory, manifest
|
||||
* content, or any other machine detail — a broken repository layout is a startup-time
|
||||
* configuration problem, not something to describe in detail to whatever eventually logs it. */
|
||||
export class PlatformPermissionConfigurationError extends Error {
|
||||
readonly code: PlatformPermissionConfigurationErrorCode;
|
||||
|
||||
constructor() {
|
||||
super("Module discovery configuration could not be resolved.");
|
||||
this.name = "PlatformPermissionConfigurationError";
|
||||
this.code = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the repository root from this compiled/source file's own location, never
|
||||
* `process.cwd()` — package-scoped test runs execute with `apps/api` as the working directory,
|
||||
* which would silently point discovery at the wrong `modules/` directory. `apps/api/dist/*.js`
|
||||
* (compiled) and `apps/api/src/*.ts` (source, under a test runner transforming TypeScript
|
||||
* on the fly) sit at the identical depth from the repository root, so the same fixed number of
|
||||
* `..` segments resolves correctly in both.
|
||||
*/
|
||||
function resolveRepositoryRoot(): string {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
}
|
||||
|
||||
interface PackageVersionFile {
|
||||
readonly version?: unknown;
|
||||
}
|
||||
|
||||
async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
let packageData: PackageVersionFile;
|
||||
try {
|
||||
packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersionFile;
|
||||
} catch {
|
||||
throw new PlatformPermissionConfigurationError();
|
||||
}
|
||||
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
|
||||
throw new PlatformPermissionConfigurationError();
|
||||
}
|
||||
return packageData.version;
|
||||
}
|
||||
|
||||
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, 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`
|
||||
* on it — and, if startup progresses that far, driving module runtime startup with this exact
|
||||
* list too. Exists because a genuine exact permission-id collision between two modules is
|
||||
* unreachable through real discovery: module ids are unique (discovery itself rejects a
|
||||
* duplicate) and every permission id is namespaced by its own module's id, so no two
|
||||
* really-discovered modules can ever contribute the same permission id. Proving
|
||||
* `PermissionRegistryError`'s own duplicate-rejection behavior in composition, and that startup
|
||||
* has created no pool before it runs, requires supplying deliberately conflicting declarations
|
||||
* directly. (A module-vs-Core collision, by contrast, *is* reachable through real discovery — a
|
||||
* module whose own id is `"core"` — and surfaces as `ModulePermissionCompositionError`, not this
|
||||
* case; `modulesDirectoryOverride` with a real fixture is what proves that one.) Never set by the
|
||||
* executable entry point.
|
||||
*/
|
||||
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||
}
|
||||
|
||||
export type ComposeStartupPermissionRegistryOptions = PrepareStartupModuleInventoryOptions;
|
||||
|
||||
export interface StartupModuleInventory {
|
||||
/** The exact discovered-module list — in discovery order — permission composition ran against.
|
||||
* Production startup (`apps/api/src/server.ts`) passes this same list to
|
||||
* `startDiscoveredModuleRuntime` rather than letting the module runtime rediscover modules a
|
||||
* second time. */
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
readonly permissionRegistry: PermissionRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers every installed module's manifest exactly once and composes the one immutable platform
|
||||
* permission registry (Core's own declarations plus every module's) from that exact discovered
|
||||
* list — the production source of truth `apps/api` enforces every request against, and the exact
|
||||
* module inventory `startDiscoveredModuleRuntime` starts from. Only manifests are read here; no
|
||||
* module server entrypoint is imported or executed, and no module lifecycle hook runs. Read the
|
||||
* platform and Module SDK versions only when this function actually executes, never at import
|
||||
* time.
|
||||
*/
|
||||
export async function prepareStartupModuleInventory(
|
||||
options: PrepareStartupModuleInventoryOptions = {},
|
||||
): Promise<StartupModuleInventory> {
|
||||
if (options.discoveredModulesOverride) {
|
||||
const modules = options.discoveredModulesOverride;
|
||||
return Object.freeze({
|
||||
modules,
|
||||
permissionRegistry: composePlatformPermissionRegistry(modules),
|
||||
});
|
||||
}
|
||||
|
||||
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")),
|
||||
]);
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion,
|
||||
...modulesDirectory,
|
||||
platformVersion,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
modules,
|
||||
permissionRegistry: composePlatformPermissionRegistry(modules),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatible wrapper for callers — currently only existing tests — that need this package's
|
||||
* composed registry without the discovered module list itself. (Doctor and `modules:validate` are
|
||||
* Core-owned and never call into `apps/api` at all; they call Core's own
|
||||
* `composePlatformPermissionRegistry` directly over their own already-discovered module list.)
|
||||
* Production `startApi` does not call this wrapper: it calls `prepareStartupModuleInventory`
|
||||
* directly so it can hand the exact same discovered list to `startDiscoveredModuleRuntime`, rather
|
||||
* than discarding it here and rediscovering modules a second time for runtime startup.
|
||||
*/
|
||||
export async function composeStartupPermissionRegistry(
|
||||
options: ComposeStartupPermissionRegistryOptions = {},
|
||||
): Promise<PermissionRegistry> {
|
||||
const inventory = await prepareStartupModuleInventory(options);
|
||||
return inventory.permissionRegistry;
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
|
||||
import { createAccountStore } from "@ekklesia/core/account";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
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 {
|
||||
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 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;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartApiOptions {
|
||||
/** Test-only escape hatch: never set by the executable entry point. See
|
||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||
/**
|
||||
* Where a verification or reset message that could not be delivered is reported.
|
||||
*
|
||||
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
|
||||
* seam because the *only* externally observable difference between "the relay is down" and "there
|
||||
* is no such account" must be this report — every HTTP response is deliberately identical — so a
|
||||
* test needs somewhere to observe the operational failure the user is never shown.
|
||||
*/
|
||||
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
||||
/** 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> {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch {
|
||||
// Cleanup failures are swallowed here deliberately: they must never mask a startup failure
|
||||
// that is already propagating, and there is no raw driver detail safe to surface.
|
||||
}
|
||||
}
|
||||
|
||||
/** 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`). */
|
||||
async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promise<void> {
|
||||
try {
|
||||
await moduleRuntime.stop();
|
||||
} catch {
|
||||
// See safeCloseDatabase: never mask the startup failure already propagating.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 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) {
|
||||
settled = true;
|
||||
reject(new Error("The API server failed to start listening."));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit runtime composition, in a fixed order:
|
||||
*
|
||||
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
||||
* platform permission registry from Core's own declarations plus every module's
|
||||
* (`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 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
|
||||
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
||||
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
||||
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
||||
* 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. 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 }),
|
||||
...(options.discoveredModulesOverride === undefined
|
||||
? {}
|
||||
: { discoveredModulesOverride: options.discoveredModulesOverride }),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// 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 {
|
||||
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,
|
||||
});
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
// Unconditional: every installation can pair a screen at `/display/setup`, whatever modules are
|
||||
// installed (ADR 0065), so a missing display host build fails here too.
|
||||
displayHostAssets = 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({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: inventory.permissionRegistry,
|
||||
store: authorizationStore,
|
||||
},
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleRealtime: moduleRuntime.realtime,
|
||||
moduleServices: services,
|
||||
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||
// inventory, and 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),
|
||||
// The same pool again: a person's own account summary and preferences (ADR 0062).
|
||||
account: createAccountStore(databaseHandle.account),
|
||||
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, clientAddressResolver, timeouts);
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Memoizes one shared promise rather than a boolean flag: a boolean only prevents a *second*
|
||||
// cleanup from starting, but a caller that arrives while the first cleanup is still in flight
|
||||
// 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. 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
|
||||
// safe because the pool is not yet closed; only then does the shared pool close, so no module
|
||||
// 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 () => {
|
||||
// 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);
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return shutdownPromise;
|
||||
};
|
||||
|
||||
return Object.freeze({ app, server, shutdown });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires `SIGINT`/`SIGTERM` to `running.shutdown()` and only then exits — extracted from `main()`
|
||||
* so it can be exercised directly in tests with a fake `running`/`exit`, without sending a real
|
||||
* process signal or invoking the real `process.exit` (which would terminate the test runner
|
||||
* itself). Production composition (`main()`, below) calls this with real `process.exit`.
|
||||
*/
|
||||
export function installShutdownSignalHandlers(
|
||||
running: Pick<RunningApi, "shutdown">,
|
||||
exit: (code: number) => void = (code) => process.exit(code),
|
||||
): void {
|
||||
let signalReceived = false;
|
||||
const handleSignal = (): void => {
|
||||
if (signalReceived) {
|
||||
return;
|
||||
}
|
||||
signalReceived = true;
|
||||
running
|
||||
.shutdown()
|
||||
.then(() => exit(0))
|
||||
.catch(() => exit(1));
|
||||
};
|
||||
process.on("SIGINT", handleSignal);
|
||||
process.on("SIGTERM", handleSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 host = loadHostConfiguration(processEnvironment);
|
||||
return {
|
||||
environment: host.environment,
|
||||
input: {
|
||||
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, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
|
||||
process.env,
|
||||
);
|
||||
const config = createApiRuntimeConfig(input, { environment });
|
||||
const running = await startApi(config, {
|
||||
configuredModulesDirectory: modulesDirectory,
|
||||
moduleSecrets,
|
||||
});
|
||||
|
||||
const address = running.server.address();
|
||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||
console.log(`Ekklesia API listening on ${config.host}:${boundPort}`);
|
||||
|
||||
installShutdownSignalHandlers(running);
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch(() => {
|
||||
console.error("Ekklesia API failed to start.");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
createDevicePrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { ACCOUNT_APPEARANCES, ACCOUNT_MOTION_PREFERENCES } from "@ekklesia/core/account";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { EKKLESIA_APPEARANCES, EKKLESIA_MOTION_PREFERENCES } from "@ekklesia/design-tokens";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ACCOUNT_PATH, ACCOUNT_PREFERENCES_PATH } from "../src/account-routes.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
createInMemoryAccountStore,
|
||||
type InMemoryAccountPerson,
|
||||
} from "./support/in-memory-account-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
/**
|
||||
* `GET /api/v1/account` and `PATCH /api/v1/account/preferences` (ADR 0062): a signed-in person's
|
||||
* own account and preferences, self-only by construction.
|
||||
*/
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const adaId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||
const graceId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
|
||||
const ada = createAuthenticatedPrincipal(adaId);
|
||||
|
||||
const people: readonly InMemoryAccountPerson[] = [
|
||||
{ email: "[email protected]", name: "Ada Lovelace", userId: adaId },
|
||||
{
|
||||
email: "[email protected]",
|
||||
name: "Grace Hopper",
|
||||
preferences: { appearance: "light", motion: "reduce" },
|
||||
userId: graceId,
|
||||
},
|
||||
];
|
||||
|
||||
interface Options {
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly evaluationFails?: boolean;
|
||||
}
|
||||
|
||||
function build(options: Options = {}) {
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async () => false },
|
||||
platformOwners: {
|
||||
isPlatformOwner: async () => {
|
||||
if (options.evaluationFails) {
|
||||
throw new Error("database down");
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ada),
|
||||
};
|
||||
const account = createInMemoryAccountStore(people);
|
||||
const app = createApiApp({
|
||||
account,
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: composePlatformPermissionRegistry([]),
|
||||
store: {} as unknown as AuthorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor([], { modules: [] }),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { account, app };
|
||||
}
|
||||
|
||||
function patch(body: unknown, init: { origin?: string | null; raw?: string } = {}): RequestInit {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (init.origin !== null) {
|
||||
headers.Origin = init.origin ?? trustedOrigin;
|
||||
}
|
||||
return { body: init.raw ?? JSON.stringify(body), headers, method: "PATCH" };
|
||||
}
|
||||
|
||||
describe("core.account.self.manage", () => {
|
||||
it("is an authenticated-baseline Core permission, so managing your own account needs no grant", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE)?.baseline).toBe("authenticated");
|
||||
});
|
||||
|
||||
it("holds Core's preference values equal to the design tokens' own", () => {
|
||||
expect([...ACCOUNT_APPEARANCES]).toEqual([...EKKLESIA_APPEARANCES]);
|
||||
expect([...ACCOUNT_MOTION_PREFERENCES]).toEqual([...EKKLESIA_MOTION_PREFERENCES]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/account", () => {
|
||||
it("returns the signed-in person's own name, email, and default preferences", async () => {
|
||||
const { app } = build();
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("vary")).toContain("Cookie");
|
||||
expect(await response.json()).toEqual({
|
||||
email: "[email protected]",
|
||||
name: "Ada Lovelace",
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns exactly name, email, and preferences — nothing else about the account", async () => {
|
||||
const { app } = build();
|
||||
const body = (await (await app.request(ACCOUNT_PATH)).json()) as Record<string, unknown>;
|
||||
expect(Object.keys(body).sort()).toEqual(["email", "name", "preferences"]);
|
||||
expect(Object.keys(body.preferences as object).sort()).toEqual(["appearance", "motion"]);
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller", async () => {
|
||||
const { account, app } = build({ principal: ANONYMOUS_PRINCIPAL });
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a paired display device, which has no account", async () => {
|
||||
const { account, app } = build({
|
||||
principal: createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
|
||||
});
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads only the session's account, whatever the request names", async () => {
|
||||
const { account, app } = build();
|
||||
const response = await app.request(`${ACCOUNT_PATH}?userId=${graceId}&id=${graceId}`, {
|
||||
headers: { "X-User-Id": graceId },
|
||||
});
|
||||
|
||||
expect(((await response.json()) as { name: string }).name).toBe("Ada Lovelace");
|
||||
expect(account.requestedUserIds).toEqual([adaId]);
|
||||
});
|
||||
|
||||
it("has no route that addresses another person's account", async () => {
|
||||
const { account, app } = build();
|
||||
for (const path of [`${ACCOUNT_PATH}/${graceId}`, `${ACCOUNT_PREFERENCES_PATH}/${graceId}`]) {
|
||||
expect((await app.request(path)).status).toBe(404);
|
||||
expect((await app.request(path, patch({ appearance: "dark" }))).status).toBe(404);
|
||||
}
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("is not found when the session's person no longer exists", async () => {
|
||||
const { app } = build({
|
||||
principal: createAuthenticatedPrincipal("99999999-9999-4999-8999-999999999999"),
|
||||
});
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "ACCOUNT_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("reports a storage failure as a sanitized 503", async () => {
|
||||
const { account, app } = build();
|
||||
account.failWith("ACCOUNT_READ_FAILED");
|
||||
const response = await app.request(ACCOUNT_PATH);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "ACCOUNT_READ_FAILED" });
|
||||
});
|
||||
|
||||
it("reports an authorization outage as a 503, never as a denial", async () => {
|
||||
// The authenticated baseline needs no owner lookup, so an owner-lookup outage cannot deny it.
|
||||
const { app } = build({ evaluationFails: true });
|
||||
expect((await app.request(ACCOUNT_PATH)).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/v1/account/preferences", () => {
|
||||
it.each([
|
||||
["appearance", "light"],
|
||||
["appearance", "dark"],
|
||||
["appearance", "system"],
|
||||
["motion", "reduce"],
|
||||
["motion", "allow"],
|
||||
["motion", "system"],
|
||||
] as const)("saves %s %s and returns everything in effect", async (name, value) => {
|
||||
const { app } = build();
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ [name]: value }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
const body = (await response.json()) as Record<string, string>;
|
||||
expect(body[name]).toBe(value);
|
||||
expect(Object.keys(body).sort()).toEqual(["appearance", "motion"]);
|
||||
|
||||
const read = (await (await app.request(ACCOUNT_PATH)).json()) as {
|
||||
preferences: Record<string, string>;
|
||||
};
|
||||
expect(read.preferences[name]).toBe(value);
|
||||
});
|
||||
|
||||
it("changes only the preference named", async () => {
|
||||
const { app } = build();
|
||||
await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ motion: "reduce" }));
|
||||
|
||||
expect(await response.json()).toEqual({ appearance: "dark", motion: "reduce" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty object", {}],
|
||||
["an unknown appearance", { appearance: "sepia" }],
|
||||
["an unknown motion", { motion: "none" }],
|
||||
["an unknown key", { appearance: "dark", radius: "large" }],
|
||||
["another person's id", { appearance: "dark", userId: "22222222-2222-4222-8222-222222222222" }],
|
||||
["an array", ["dark"]],
|
||||
["null", null],
|
||||
])("refuses %s with nothing changed", async (_label, body) => {
|
||||
const { account, app } = build();
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(body));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a body that is not JSON", async () => {
|
||||
const { app } = build();
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(undefined, { raw: "{" }));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses an untrusted or missing Origin before anything else", async () => {
|
||||
const { account, app } = build();
|
||||
for (const origin of ["https://elsewhere.example.test", null] as const) {
|
||||
const response = await app.request(
|
||||
ACCOUNT_PREFERENCES_PATH,
|
||||
patch({ appearance: "dark" }, { origin }),
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
}
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an oversized body", async () => {
|
||||
const { app } = build();
|
||||
const response = await app.request(
|
||||
ACCOUNT_PREFERENCES_PATH,
|
||||
patch(undefined, { raw: JSON.stringify({ appearance: "dark", pad: "x".repeat(20_000) }) }),
|
||||
);
|
||||
expect(response.status).toBe(413);
|
||||
});
|
||||
|
||||
it("refuses an anonymous caller and a device", async () => {
|
||||
for (const principal of [
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
|
||||
]) {
|
||||
const { account, app } = build({ principal });
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
||||
expect(response.status).toBe(403);
|
||||
expect(account.requestedUserIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("changes only the session's own preferences, never another person's", async () => {
|
||||
const { account, app } = build();
|
||||
await app.request(
|
||||
`${ACCOUNT_PREFERENCES_PATH}?userId=${graceId}`,
|
||||
patch({ appearance: "dark", motion: "allow" }),
|
||||
);
|
||||
|
||||
expect(account.requestedUserIds).toEqual([adaId]);
|
||||
expect((await account.readAccount(graceId))?.preferences).toEqual({
|
||||
appearance: "light",
|
||||
motion: "reduce",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a storage failure as a sanitized 503", async () => {
|
||||
const { account, app } = build();
|
||||
account.failWith("ACCOUNT_WRITE_FAILED");
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "ACCOUNT_WRITE_FAILED" });
|
||||
});
|
||||
|
||||
it("answers a preflight from the Dashboard origin with PATCH allowed", async () => {
|
||||
const { app } = build();
|
||||
const response = await app.request(ACCOUNT_PREFERENCES_PATH, {
|
||||
headers: {
|
||||
"Access-Control-Request-Method": "PATCH",
|
||||
Origin: trustedOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-methods")).toContain("PATCH");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
createPermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "../src/authorization-middleware.js";
|
||||
|
||||
const testPermission = parsePermissionId("core.test.restricted-thing");
|
||||
const unregisteredPermission = parsePermissionId("core.test.unregistered-thing");
|
||||
const registry = createPermissionRegistry([
|
||||
{ baseline: "restricted", description: "A restricted test permission.", id: testPermission },
|
||||
]);
|
||||
|
||||
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeEvaluator(overrides: Partial<AuthorizationEvaluator> = {}): AuthorizationEvaluator {
|
||||
return {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTestApp(options: Partial<AuthorizationMiddlewareOptions> = {}) {
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const evaluator = options.evaluator ?? fakeEvaluator();
|
||||
const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry, runtime };
|
||||
|
||||
const app = new Hono();
|
||||
app.get("/protected", requirePermission(middlewareOptions, testPermission), (c) => {
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
return c.json({ principalKind: principal.kind });
|
||||
});
|
||||
app.get("/unregistered", requirePermission(middlewareOptions, unregisteredPermission), (c) =>
|
||||
c.json({ ok: true }),
|
||||
);
|
||||
app.get("/unprotected", (c) => {
|
||||
let threw = false;
|
||||
try {
|
||||
getAuthorizedPrincipal(c);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
return c.json({ threw });
|
||||
});
|
||||
|
||||
return { app, evaluator, runtime };
|
||||
}
|
||||
|
||||
describe("requirePermission", () => {
|
||||
it("allows the request through and stores the resolved principal on context when the decision is allowed", async () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
||||
});
|
||||
const { app } = buildTestApp({ runtime });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ principalKind: "authenticated" });
|
||||
});
|
||||
|
||||
it("returns a sanitized 403 when the evaluator denies", async () => {
|
||||
const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) });
|
||||
const { app } = buildTestApp({ evaluator });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("returns a sanitized 403 for an unregistered permission without ever resolving the principal", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const { app } = buildTestApp({ runtime });
|
||||
|
||||
const response = await app.request("/unregistered");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a sanitized 503, not a 403, when principal resolution fails", async () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
||||
});
|
||||
const { app } = buildTestApp({ runtime });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
||||
});
|
||||
|
||||
it("returns a sanitized 503, not a 403, when evaluation itself fails", async () => {
|
||||
const evaluator = fakeEvaluator({
|
||||
authorize: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
||||
});
|
||||
const { app } = buildTestApp({ evaluator });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
});
|
||||
|
||||
it("never leaks a raw infrastructure error message through either sanitized 503", async () => {
|
||||
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
|
||||
const evaluator = fakeEvaluator({
|
||||
authorize: vi.fn().mockRejectedValue(new Error(hostileMessage)),
|
||||
});
|
||||
const { app } = buildTestApp({ evaluator });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
const text = await response.text();
|
||||
|
||||
expect(text).not.toContain("hunter2");
|
||||
expect(text).not.toContain("db.internal.example");
|
||||
});
|
||||
|
||||
it("passes the registry's own baseline and permission id to the evaluator, never a client-supplied value", async () => {
|
||||
const evaluator = fakeEvaluator();
|
||||
const { app } = buildTestApp({ evaluator });
|
||||
|
||||
// A hostile client cannot even express "use this baseline instead" — there is no request
|
||||
// field the middleware reads for it — but sending an unrelated header proves it is ignored.
|
||||
await app.request("/protected", { headers: { "x-baseline": "public" } });
|
||||
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseline: "restricted", permission: testPermission }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requirePermission with module-owned declarations", () => {
|
||||
// requirePermission has no concept of "Core" versus "module" — it only ever consults whatever
|
||||
// registry it is given. These declarations are shaped exactly like what
|
||||
// composePlatformPermissionRegistry would contribute from a real installed module (an id
|
||||
// namespaced under a module, not `core.*`), proving the shared enforcement mechanism works
|
||||
// identically for module-owned permissions without any module-specific code path.
|
||||
const modulePublicPermission = parsePermissionId("calendar.events.list");
|
||||
const moduleAuthenticatedPermission = parsePermissionId("calendar.events.subscribe");
|
||||
const moduleRestrictedPermission = parsePermissionId("calendar.events.read");
|
||||
const moduleRegistry = createPermissionRegistry([
|
||||
{
|
||||
baseline: "public",
|
||||
description: "List public calendar events.",
|
||||
id: modulePublicPermission,
|
||||
},
|
||||
{
|
||||
baseline: "authenticated",
|
||||
description: "Subscribe to calendar event updates.",
|
||||
id: moduleAuthenticatedPermission,
|
||||
},
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "Read calendar events.",
|
||||
id: moduleRestrictedPermission,
|
||||
},
|
||||
]);
|
||||
|
||||
function buildModuleApp(
|
||||
permissionId: typeof modulePublicPermission,
|
||||
options: Partial<AuthorizationMiddlewareOptions> = {},
|
||||
) {
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const evaluator = options.evaluator ?? fakeEvaluator();
|
||||
const middlewareOptions: AuthorizationMiddlewareOptions = {
|
||||
evaluator,
|
||||
registry: moduleRegistry,
|
||||
runtime,
|
||||
};
|
||||
const app = new Hono();
|
||||
app.get("/module-protected", requirePermission(middlewareOptions, permissionId), (c) =>
|
||||
c.json({ ok: true }),
|
||||
);
|
||||
return { app, evaluator, runtime };
|
||||
}
|
||||
|
||||
it("resolves a module public permission's trusted baseline and passes it to the evaluator unchanged", async () => {
|
||||
const evaluator = fakeEvaluator();
|
||||
const { app } = buildModuleApp(modulePublicPermission, { evaluator });
|
||||
|
||||
const response = await app.request("/module-protected");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseline: "public", permission: modulePublicPermission }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a module authenticated permission's trusted baseline and passes it to the evaluator unchanged", async () => {
|
||||
const evaluator = fakeEvaluator();
|
||||
const { app } = buildModuleApp(moduleAuthenticatedPermission, { evaluator });
|
||||
|
||||
await app.request("/module-protected");
|
||||
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseline: "authenticated",
|
||||
permission: moduleAuthenticatedPermission,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a module restricted permission's trusted baseline and passes it to the evaluator unchanged", async () => {
|
||||
const evaluator = fakeEvaluator();
|
||||
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
|
||||
|
||||
await app.request("/module-protected");
|
||||
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseline: "restricted",
|
||||
permission: moduleRestrictedPermission,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows an anonymous principal for a module permission the evaluator authorizes", async () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
});
|
||||
const { app } = buildModuleApp(modulePublicPermission, { runtime });
|
||||
|
||||
const response = await app.request("/module-protected");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows an authenticated principal for a module permission the evaluator authorizes", async () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
||||
});
|
||||
const { app } = buildModuleApp(moduleRestrictedPermission, { runtime });
|
||||
|
||||
const response = await app.request("/module-protected");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies a module permission the evaluator does not authorize", async () => {
|
||||
const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) });
|
||||
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
|
||||
|
||||
const response = await app.request("/module-protected");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
});
|
||||
|
||||
it("denies a module permission absent from the registry before ever resolving the principal or consulting the evaluator", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const evaluator = fakeEvaluator();
|
||||
const absentModulePermission = parsePermissionId("scheduler.jobs.read");
|
||||
const { app } = buildModuleApp(absentModulePermission, { evaluator, runtime });
|
||||
|
||||
const response = await app.request("/module-protected");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never lets a client-supplied baseline influence the decision for a module permission", async () => {
|
||||
const evaluator = fakeEvaluator();
|
||||
const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator });
|
||||
|
||||
// A hostile client cannot make a restricted module permission behave as public — there is no
|
||||
// request field the middleware reads for a baseline override.
|
||||
await app.request("/module-protected", {
|
||||
headers: { "x-baseline": "public", "x-permission-baseline": "public" },
|
||||
});
|
||||
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseline: "restricted" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthorizedPrincipal", () => {
|
||||
it("throws when called on a route requirePermission never protected", async () => {
|
||||
const { app } = buildTestApp();
|
||||
|
||||
const response = await app.request("/unprotected");
|
||||
|
||||
expect(await response.json()).toEqual({ threw: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireTrustedOrigin", () => {
|
||||
const trustedOrigins = new Set(["https://dashboard.example.test"]);
|
||||
|
||||
function buildOriginApp() {
|
||||
const app = new Hono();
|
||||
app.post("/mutate", requireTrustedOrigin(trustedOrigins), (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("allows a request from a trusted origin", async () => {
|
||||
const app = buildOriginApp();
|
||||
|
||||
const response = await app.request("/mutate", {
|
||||
headers: { origin: "https://dashboard.example.test" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a request with no Origin header at all", async () => {
|
||||
const app = buildOriginApp();
|
||||
|
||||
const response = await app.request("/mutate", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("rejects a request from an untrusted origin", async () => {
|
||||
const app = buildOriginApp();
|
||||
|
||||
const response = await app.request("/mutate", {
|
||||
headers: { origin: "https://evil.example.test" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
});
|
||||
@@ -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]" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
||||
|
||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||
|
||||
function validInput() {
|
||||
return {
|
||||
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
port: 3000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("API runtime configuration", () => {
|
||||
it("accepts valid API and identity 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"],
|
||||
},
|
||||
port: 3000,
|
||||
});
|
||||
expect(Object.isFrozen(config)).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults host to loopback and port to 3000 when omitted", () => {
|
||||
const { host: _host, port: _port, ...rest } = validInput();
|
||||
const config = createApiRuntimeConfig(rest, { environment: "production" });
|
||||
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(3000);
|
||||
});
|
||||
|
||||
it("permits an explicit non-loopback bind address", () => {
|
||||
const config = createApiRuntimeConfig(
|
||||
{ ...validInput(), host: "0.0.0.0" },
|
||||
{ environment: "production" },
|
||||
);
|
||||
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
it("rejects a missing database URL", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig({ ...validInput(), databaseUrl: undefined });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(ApiConfigError);
|
||||
expect(captured).toMatchObject({ code: "DATABASE_URL_MISSING" });
|
||||
});
|
||||
|
||||
it("rejects an invalid database URL", () => {
|
||||
for (const databaseUrl of ["", "not-a-url", "mysql://example.test/db", "http://example.test"]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig({ ...validInput(), databaseUrl });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(ApiConfigError);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty listen host", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig({ ...validInput(), host: "" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "HOST_INVALID" });
|
||||
});
|
||||
|
||||
it("rejects an invalid listen port", () => {
|
||||
for (const port of [-1, 1.5, 70_000, Number.NaN, "not-a-port"]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig({ ...validInput(), port });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PORT_INVALID" });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts port 0 as an explicit request for an OS-assigned ephemeral port", () => {
|
||||
const config = createApiRuntimeConfig({ ...validInput(), port: 0 });
|
||||
expect(config.port).toBe(0);
|
||||
});
|
||||
|
||||
it("delegates identity validation to Core's createIdentityConfig, rejecting a short secret", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig({
|
||||
...validInput(),
|
||||
identity: { ...validInput().identity, secret: "too-short" },
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" });
|
||||
});
|
||||
|
||||
it("explicitly allows local development origins only when environment is development", () => {
|
||||
const config = createApiRuntimeConfig(
|
||||
{
|
||||
databaseUrl: validInput().databaseUrl,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: "http://localhost:3000",
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
},
|
||||
port: 3000,
|
||||
},
|
||||
{ environment: "development" },
|
||||
);
|
||||
|
||||
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;
|
||||
try {
|
||||
createApiRuntimeConfig({ ...validInput(), databaseUrl: hostileUrl, port: -1 });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(ApiConfigError);
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}\n${JSON.stringify(captured)}`;
|
||||
expect(publicText).not.toContain(hostileUrl);
|
||||
expect(publicText).not.toContain("s3cr3t");
|
||||
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,157 @@
|
||||
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",
|
||||
appearance: "file:///surface/browser/assets/ekklesia-dashboard/appearance.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 three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||
const script = "export const dashboardHost = true;\n";
|
||||
const appearanceScript = "(() => {})();\n";
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce(script)
|
||||
.mockResolvedValueOnce(appearanceScript);
|
||||
|
||||
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"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/appearance.js"), "utf8"],
|
||||
]);
|
||||
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||
expect(assets).toEqual({
|
||||
appearanceScript,
|
||||
appearanceScriptEtag: sha256Etag(appearanceScript),
|
||||
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("fails when the appearance script is missing even though the page and script were read", async () => {
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||
.mockResolvedValueOnce("export {};\n")
|
||||
.mockRejectedValueOnce(new Error("ENOENT appearance.js"));
|
||||
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("rejects an empty page, script, or appearance script as an unavailable shell build", () => {
|
||||
for (const [html, script, appearance] of [
|
||||
["", "export {};", "(() => {})();"],
|
||||
["<html></html>", " \n", "(() => {})();"],
|
||||
["<html></html>", "export {};", " "],
|
||||
] as const) {
|
||||
expect(() => createDashboardHostAssets(html, script, appearance)).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")
|
||||
.mockResolvedValueOnce("(() => {})();\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,668 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleRouteHandler,
|
||||
parseModuleManifest,
|
||||
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,
|
||||
type DiscoveredModule,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
isReservedModuleId,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleConfiguration,
|
||||
RESERVED_DISPLAY_HOST_NAMES,
|
||||
RESERVED_MODULE_IDS,
|
||||
RESERVED_SETTINGS_NAMES,
|
||||
RESERVED_TOP_LEVEL_NAMES,
|
||||
} 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_APPEARANCE_SCRIPT_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
DashboardHostConfigurationError,
|
||||
createDashboardHostRoutes,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { MODULE_NAVIGATION_ICONS } from "@ekklesia/contracts";
|
||||
import { DISPLAY_SETUP_PATH as SHELL_DISPLAY_SETUP_PATH } from "@ekklesia/shell";
|
||||
import {
|
||||
DASHBOARD_PLATFORM_PAGES,
|
||||
HOST_NAMESPACES,
|
||||
SETTINGS_SECTIONS,
|
||||
} from "@ekklesia/shell/dashboard-routes";
|
||||
import { DASHBOARD_MODULE_ICON_NAMES } from "@ekklesia/shell/module-icons";
|
||||
import {
|
||||
DISPLAY_SETUP_PAGE_URL,
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
} from "../src/module-display-path.js";
|
||||
import {
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
DISPLAY_SERVICE_WORKER_PATH,
|
||||
} from "../src/module-display-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
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);
|
||||
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
|
||||
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_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 });
|
||||
}
|
||||
|
||||
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
|
||||
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
|
||||
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: {
|
||||
server: `/modules/${id}/dist/server/index.js`,
|
||||
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
|
||||
},
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
options: {
|
||||
readonly assets?: DashboardHostAssets;
|
||||
readonly configuration?: ModuleLifecycleConfiguration;
|
||||
readonly discovered?: readonly DiscoveredModule[];
|
||||
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: {
|
||||
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets:
|
||||
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_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("Dashboard deep links (ADR 0057)", () => {
|
||||
const installed = () => [
|
||||
discoveredModule("signage", { ui: true }),
|
||||
discoveredModule("calendar", { ui: true }),
|
||||
discoveredModule("retired", { ui: true }),
|
||||
// A display-only module has no Dashboard page (ADR 0033).
|
||||
discoveredModule("lobby-screen", { ui: false }),
|
||||
];
|
||||
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
|
||||
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
|
||||
|
||||
const deepLinks = [
|
||||
"/settings",
|
||||
"/settings/",
|
||||
"/settings/displays/add",
|
||||
"/account",
|
||||
"/account/appearance",
|
||||
"/signage",
|
||||
"/signage/",
|
||||
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
|
||||
"/signage/timelines",
|
||||
"/signage/timelines/a1b2",
|
||||
"/calendar",
|
||||
"/retired",
|
||||
];
|
||||
|
||||
it("serves the shell's own platform pages, from the shell's own list", () => {
|
||||
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
|
||||
});
|
||||
|
||||
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
|
||||
const { app } = buildApp({ configuration, discovered: installed() });
|
||||
|
||||
for (const path of deepLinks) {
|
||||
const response = await app.request(path);
|
||||
expect(response.status, path).toBe(200);
|
||||
expect(await response.text(), path).toBe(PAGE_HTML);
|
||||
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("cache-control"), path).toBe("no-cache");
|
||||
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
|
||||
expectPageSecurityHeaders(response);
|
||||
}
|
||||
expect(readFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates a deep link with the page's one validator", async () => {
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
for (const path of ["/settings/displays", "/signage"]) {
|
||||
const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
|
||||
expect(response.status, path).toBe(304);
|
||||
expect(await response.text(), path).toBe("");
|
||||
expectPageSecurityHeaders(response);
|
||||
}
|
||||
});
|
||||
|
||||
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
|
||||
const { app } = buildApp({ configuration, discovered: installed() });
|
||||
|
||||
for (const path of [
|
||||
"/definitely-not-a-real-module",
|
||||
"/definitely-not-a-real-module/timelines",
|
||||
// Installed, but with no Dashboard UI.
|
||||
"/lobby-screen",
|
||||
// Look-alikes of real pages.
|
||||
"/signages",
|
||||
"/signage-old",
|
||||
"/Signage",
|
||||
"/settings-old",
|
||||
"/accounts",
|
||||
// The former implementation prefix is not a namespace at all.
|
||||
"/modules",
|
||||
"/modules/signage",
|
||||
]) {
|
||||
await expectSanitizedNotFound(await app.request(path), path);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves a module's page only while that module is installed", async () => {
|
||||
const { app } = buildApp({ discovered: [] });
|
||||
|
||||
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
|
||||
expect((await app.request("/settings")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
for (const path of [
|
||||
"/signage/app.js",
|
||||
"/settings/assets/ekklesia-dashboard/app.js",
|
||||
"/account/index.html",
|
||||
]) {
|
||||
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers no other method at a Dashboard page", async () => {
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
for (const path of ["/settings/displays", "/signage"]) {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses at composition a module page that would shadow a platform namespace", () => {
|
||||
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
|
||||
expect(
|
||||
() =>
|
||||
createDashboardHostRoutes(
|
||||
createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
|
||||
{ modulePages },
|
||||
),
|
||||
JSON.stringify(modulePages),
|
||||
).toThrow(DashboardHostConfigurationError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the platform's reserved top-level names (ADR 0057)", () => {
|
||||
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
|
||||
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
|
||||
|
||||
const topLevel = new Set(
|
||||
app.routes
|
||||
.map((route) => route.path.split("/")[1] ?? "")
|
||||
.filter((segment) => segment !== "" && segment !== "*"),
|
||||
);
|
||||
topLevel.delete("signage");
|
||||
|
||||
// This composition publishes no display, so the display host's routes are absent here; they
|
||||
// are checked through their own path constants below.
|
||||
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
|
||||
for (const segment of topLevel) {
|
||||
expect(isReservedModuleId(segment), segment).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
|
||||
for (const path of [
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
DISPLAY_SERVICE_WORKER_PATH,
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
]) {
|
||||
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
|
||||
}
|
||||
expect([...RESERVED_TOP_LEVEL_NAMES].sort()).toEqual([
|
||||
"account",
|
||||
"api",
|
||||
"assets",
|
||||
"display",
|
||||
"settings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("include every Dashboard platform page the shell interprets", () => {
|
||||
for (const page of DASHBOARD_PLATFORM_PAGES) {
|
||||
expect(RESERVED_TOP_LEVEL_NAMES, page).toContain(page);
|
||||
}
|
||||
});
|
||||
|
||||
it("are exactly the Dashboard's platform pages plus the host namespaces it never treats as pages (ADR 0063)", () => {
|
||||
// With module routes nested beneath `/<module-id>/`, the shell must know every other top-level
|
||||
// name: `/api/v1/principal` must never read as the route `v1/principal` of a module called `api`.
|
||||
expect([...DASHBOARD_PLATFORM_PAGES, ...HOST_NAMESPACES].sort()).toEqual([
|
||||
...RESERVED_TOP_LEVEL_NAMES,
|
||||
]);
|
||||
});
|
||||
|
||||
it("include exactly the Settings sections the shell interprets, so /settings/<module-id> never collides (ADR 0061)", () => {
|
||||
expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]);
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(isReservedModuleId(section), section).toBe(true);
|
||||
}
|
||||
expect(RESERVED_MODULE_IDS).toEqual(
|
||||
[
|
||||
...RESERVED_TOP_LEVEL_NAMES,
|
||||
...RESERVED_SETTINGS_NAMES,
|
||||
...RESERVED_DISPLAY_HOST_NAMES,
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("include the display host's own setup page, so /display/<module-id> never collides (ADR 0065)", () => {
|
||||
expect([...RESERVED_DISPLAY_HOST_NAMES]).toEqual(["setup"]);
|
||||
expect(DISPLAY_SETUP_PAGE_URL).toBe("/display/setup");
|
||||
// The shell opens and links to exactly the address the API serves.
|
||||
expect(SHELL_DISPLAY_SETUP_PATH).toBe(DISPLAY_SETUP_PAGE_URL);
|
||||
expect(isReservedModuleId(DISPLAY_SETUP_PAGE_URL.split("/")[2] ?? "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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("GET /assets/ekklesia-dashboard/appearance.js (the pre-paint appearance script)", () => {
|
||||
it("serves the exact pre-paint script as JavaScript with revalidation and its own strong ETag", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(APPEARANCE_SCRIPT);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
// Code only: nothing about any viewer, and nothing that varies by request.
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
expect(response.headers.get("vary")).toBeNull();
|
||||
});
|
||||
|
||||
it("answers a matching If-None-Match with a 304, and does not accept the app script's validator", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const notModified = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": APPEARANCE_SCRIPT_ETAG },
|
||||
});
|
||||
expect(notModified.status).toBe(304);
|
||||
expect(notModified.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
|
||||
|
||||
const other = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||
});
|
||||
expect(other.status).toBe(200);
|
||||
});
|
||||
|
||||
it("lives beside the app script, in the Dashboard's own asset namespace", () => {
|
||||
expect(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH).toBe("/assets/ekklesia-dashboard/appearance.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard host boundaries", () => {
|
||||
it("serves nothing but its 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",
|
||||
"/assets/ekklesia-dashboard/appearance.js.map",
|
||||
"/assets/ekklesia-dashboard/appearance.js/",
|
||||
"/assets/ekklesia-dashboard/APPEARANCE.JS",
|
||||
"/appearance.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, APPEARANCE_SCRIPT);
|
||||
|
||||
for (const assets of [
|
||||
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||
{ ...consistent, appearanceScriptEtag: SCRIPT_ETAG },
|
||||
{ ...consistent, appearanceScript: `${APPEARANCE_SCRIPT}// tampered` },
|
||||
{
|
||||
appearanceScript: APPEARANCE_SCRIPT,
|
||||
appearanceScriptEtag: APPEARANCE_SCRIPT_ETAG,
|
||||
html: "",
|
||||
htmlEtag: sha256Etag(""),
|
||||
script: SCRIPT,
|
||||
scriptEtag: SCRIPT_ETAG,
|
||||
},
|
||||
]) {
|
||||
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module navigation icons (ADR 0063)", () => {
|
||||
it("are the same set in the manifest contract and in the Dashboard that draws them", () => {
|
||||
// The platform cannot accept an icon the Dashboard cannot draw, nor draw one it would refuse.
|
||||
expect([...DASHBOARD_MODULE_ICON_NAMES]).toEqual([...MODULE_NAVIGATION_ICONS]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,716 @@
|
||||
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 { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { 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;
|
||||
/** Present, the fixture is a manifest v9 module declaring this navigation (ADR 0063). */
|
||||
readonly navigation?: 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 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: fixture.navigation === undefined ? 8 : 9,
|
||||
name: fixture.name,
|
||||
...(fixture.navigation === undefined ? {} : { navigation: fixture.navigation }),
|
||||
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 },
|
||||
account: createInMemoryAccountStore(),
|
||||
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 navigation: {
|
||||
readonly icon: string | null;
|
||||
readonly pages: readonly {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly path: 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", "navigation"]);
|
||||
// A module that declares no navigation — this manifest v8 fixture — is drawn with the generic
|
||||
// icon and has its root as its only page (ADR 0063). Never a group or an order.
|
||||
expect(livestream?.navigation).toEqual({ icon: null, pages: [] });
|
||||
});
|
||||
|
||||
it("carries a manifest v9 module's own icon and pages beside its descriptor, in declared order", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{
|
||||
id: "signage",
|
||||
name: "Signage",
|
||||
navigation: {
|
||||
icon: "monitor-play",
|
||||
pages: [
|
||||
{ id: "playback", label: "Playback", path: "playback" },
|
||||
{ id: "timelines", label: "Timelines", path: "timelines" },
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: "calendar", name: "Calendar" },
|
||||
],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const navigation = await navigationOf(app);
|
||||
const signage = navigation.ungrouped.find((entry) => entry.module.id === "signage");
|
||||
const calendar = navigation.ungrouped.find((entry) => entry.module.id === "calendar");
|
||||
|
||||
expect(signage?.navigation).toEqual({
|
||||
icon: "monitor-play",
|
||||
pages: [
|
||||
{ id: "playback", label: "Playback", path: "playback" },
|
||||
{ id: "timelines", label: "Timelines", path: "timelines" },
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
],
|
||||
});
|
||||
// The host-neutral descriptor is untouched by navigation: it lives beside it, never inside.
|
||||
expect(Object.keys(signage?.module ?? {}).sort()).toEqual(
|
||||
Object.keys(calendar?.module ?? {}).sort(),
|
||||
);
|
||||
// A manifest v8 module beside it keeps the generic icon and its single root page.
|
||||
expect(calendar?.navigation).toEqual({ icon: null, pages: [] });
|
||||
});
|
||||
|
||||
it("refuses at discovery a v9 module whose navigation is unsafe, so it never reaches the sidebar", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [
|
||||
{ id: "calendar", name: "Calendar" },
|
||||
{
|
||||
id: "escape",
|
||||
name: "Escape",
|
||||
navigation: { pages: [{ id: "out", label: "Out", path: "../settings" }] },
|
||||
},
|
||||
],
|
||||
principal: signedIn(),
|
||||
}).catch((error: unknown) => ({ app: undefined, error }));
|
||||
|
||||
// Discovery is atomic (ADR 0005): an invalid manifest fails the whole inventory rather than
|
||||
// quietly dropping one module, exactly as for any other manifest error.
|
||||
expect(app).toBeUndefined();
|
||||
});
|
||||
|
||||
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,250 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
parseEkklesiaUserId,
|
||||
parseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleConfiguration,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
DASHBOARD_SETTINGS_PATH,
|
||||
type DashboardSettingsResponse,
|
||||
} from "../src/dashboard-settings-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
/**
|
||||
* `GET /api/v1/dashboard/settings` (ADR 0061): which Settings destinations the viewer may see,
|
||||
* evaluated by the server through Core's one authorization evaluator. Presentation hints, never
|
||||
* authority: every Settings data route keeps its own enforcement.
|
||||
*/
|
||||
|
||||
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
|
||||
const viewer = createAuthenticatedPrincipal(viewerId);
|
||||
|
||||
function discovered(
|
||||
id: string,
|
||||
options: { readonly name?: string; readonly settings: boolean },
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
configuration: options.settings
|
||||
? [
|
||||
{
|
||||
default: "Hello",
|
||||
description: "A greeting.",
|
||||
key: "greeting",
|
||||
maxLength: 40,
|
||||
type: "string",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 8,
|
||||
name: options.name ?? `${id} module`,
|
||||
permissions: [],
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: {
|
||||
server: `/modules/${id}/dist/server/index.js`,
|
||||
ui: `/modules/${id}/dist/ui/index.js`,
|
||||
},
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly owner?: boolean;
|
||||
readonly grants?: readonly string[];
|
||||
readonly modules?: readonly DiscoveredModule[];
|
||||
readonly configuration?: ModuleLifecycleConfiguration;
|
||||
readonly ownerLookupFails?: boolean;
|
||||
}
|
||||
|
||||
function build(options: Options = {}) {
|
||||
const modules = options.modules ?? [
|
||||
discovered("signage", { name: "Signage", settings: true }),
|
||||
discovered("calendar", { name: "Calendar", settings: true }),
|
||||
discovered("retired", { name: "Archive", settings: true }),
|
||||
discovered("plain", { name: "Plain", settings: false }),
|
||||
];
|
||||
const isPlatformOwner = vi.fn(async () => {
|
||||
if (options.ownerLookupFails) {
|
||||
throw new Error("database down");
|
||||
}
|
||||
return options.owner === true;
|
||||
});
|
||||
const grants = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async ({ permission }) => grants.has(String(permission)) },
|
||||
platformOwners: { isPlatformOwner },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? viewer),
|
||||
};
|
||||
const store = {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn(),
|
||||
isPlatformOwner,
|
||||
listGrantsForUser: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(
|
||||
modules,
|
||||
options.configuration ?? { modules: [{ enabled: false, moduleId: "retired" }] },
|
||||
),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
});
|
||||
return { app, isPlatformOwner };
|
||||
}
|
||||
|
||||
async function settings(options: Options = {}) {
|
||||
const { app } = build(options);
|
||||
const response = await app.request(DASHBOARD_SETTINGS_PATH);
|
||||
const body =
|
||||
response.status === 200 ? ((await response.json()) as DashboardSettingsResponse) : undefined;
|
||||
return { body, response };
|
||||
}
|
||||
|
||||
describe("GET /api/v1/dashboard/settings", () => {
|
||||
it("shows a signed-in person with no administrative permission nothing at all", async () => {
|
||||
const { body, response } = await settings();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body).toEqual({ modules: [], sections: [] });
|
||||
});
|
||||
|
||||
it("refuses an anonymous viewer, like the navigation it sits beside", async () => {
|
||||
const { response } = await settings({ principal: ANONYMOUS_PRINCIPAL });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("shows the platform owner every section, in order, and every module with settings", async () => {
|
||||
const { body } = await settings({ owner: true });
|
||||
|
||||
expect(body).toEqual({
|
||||
modules: [
|
||||
{ available: false, id: "retired", name: "Archive" },
|
||||
{ available: true, id: "calendar", name: "Calendar" },
|
||||
{ available: true, id: "signage", name: "Signage" },
|
||||
],
|
||||
sections: ["general", "branding", "displays", "discover", "system"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Displays exactly to whoever may manage devices", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_DEVICES_MANAGE)] });
|
||||
|
||||
expect(body).toEqual({ modules: [], sections: ["displays"] });
|
||||
});
|
||||
|
||||
it("shows Discover — module lifecycle — exactly to whoever may manage which modules run", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)] });
|
||||
|
||||
expect(body).toEqual({ modules: [], sections: ["discover"] });
|
||||
});
|
||||
|
||||
it("lists modules' settings exactly to whoever may configure modules, flagging disabled ones", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)] });
|
||||
|
||||
// A module that declares no configuration has no settings page; a disabled module is listed so
|
||||
// its address can say it is unavailable, but marked so the Dashboard does not navigate to it.
|
||||
expect(body).toEqual({
|
||||
modules: [
|
||||
{ available: false, id: "retired", name: "Archive" },
|
||||
{ available: true, id: "calendar", name: "Calendar" },
|
||||
{ available: true, id: "signage", name: "Signage" },
|
||||
],
|
||||
sections: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps General, Branding, and System to the platform owner until each has a permission of its own", async () => {
|
||||
const { body } = await settings({
|
||||
grants: [
|
||||
String(CORE_DEVICES_MANAGE),
|
||||
String(CORE_MODULES_LIFECYCLE_MANAGE),
|
||||
String(CORE_MODULES_CONFIGURATION_MANAGE),
|
||||
],
|
||||
});
|
||||
|
||||
expect(body?.sections).toEqual(["displays", "discover"]);
|
||||
});
|
||||
|
||||
it("is a 503, never an empty answer, when authorization cannot be evaluated", async () => {
|
||||
const { response } = await settings({ ownerLookupFails: true });
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
});
|
||||
|
||||
it("is a hint, not authority: the data behind a section stays refused without its permission", async () => {
|
||||
const { app } = build({ grants: [] });
|
||||
|
||||
for (const path of [
|
||||
"/api/v1/module-lifecycle",
|
||||
"/api/v1/module-configuration/signage",
|
||||
"/api/v1/grants",
|
||||
]) {
|
||||
const response = await app.request(path);
|
||||
expect(response.status, path).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||
vi.mock("@ekklesia/shell/display-assets", () => ({
|
||||
displayHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/display/index.html",
|
||||
script: "file:///surface/browser/display/app.js",
|
||||
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
createDisplayHostAssets,
|
||||
DisplayHostAssetError,
|
||||
loadDisplayHostAssets,
|
||||
} from "../src/display-host-assets.js";
|
||||
|
||||
describe("display host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||
const script = "export const displayHost = true;\n";
|
||||
const serviceWorker = "export const displayWorker = true;\n";
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce(script)
|
||||
.mockResolvedValueOnce(serviceWorker);
|
||||
|
||||
const assets = await loadDisplayHostAssets();
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||
]);
|
||||
expect(assets).toEqual({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
serviceWorker,
|
||||
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||
});
|
||||
expect(Object.isFrozen(assets)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||
readFileMock.mockRejectedValueOnce(
|
||||
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||
);
|
||||
|
||||
const failure = await loadDisplayHostAssets().catch((error: unknown) => error);
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeInstanceOf(DisplayHostAssetError);
|
||||
expect(failure).toMatchObject({
|
||||
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
|
||||
message: "The display host browser assets are unavailable.",
|
||||
name: "DisplayHostAssetError",
|
||||
});
|
||||
expect((failure as Error).stack).toBe(
|
||||
"DisplayHostAssetError: The display host browser assets are unavailable.",
|
||||
);
|
||||
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||
expect(publicText).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||
for (const [html, script, serviceWorker] of [
|
||||
["", "export {};", "export {};"],
|
||||
["<html></html>", " \n", "export {};"],
|
||||
["<html></html>", "export {};", " \n"],
|
||||
] as const) {
|
||||
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
|
||||
DisplayHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,816 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createBootstrapIdentityCreator,
|
||||
createIdentityDrizzleDatabase,
|
||||
type IdentityNotificationFailure,
|
||||
} from "@ekklesia/core/identity";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "@ekklesia/core/migrations";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { SMTPServer } from "smtp-server";
|
||||
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { type RunningApi, startApi } from "../src/server.js";
|
||||
|
||||
/**
|
||||
* Email verification and password recovery, end to end (ADR 0038).
|
||||
*
|
||||
* Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better
|
||||
* Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The
|
||||
* verification and reset links are **extracted from the delivered message** and then followed, which
|
||||
* is the only way to prove the whole path rather than the pieces — a fake mail function would prove
|
||||
* that a callback was invoked and nothing about whether the resulting link works.
|
||||
*
|
||||
* `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the
|
||||
* internet and no real email is ever sent.
|
||||
*
|
||||
* Public signup is disabled in every scenario, matching production. Accounts that are not the
|
||||
* bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses,
|
||||
* which is deliberately not reachable over HTTP.
|
||||
*/
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
const dashboardOrigin = "http://dashboard.localhost:5173";
|
||||
const integrationSecret = "integration-test-secret-0123456789abcdefgh";
|
||||
const initialPassword = "correct horse battery staple";
|
||||
const replacementPassword = "an entirely different passphrase";
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const url = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof url !== "string" || url.trim() === "") {
|
||||
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function databaseUrl(name: string): string {
|
||||
const url = new URL(baseDatabaseUrl());
|
||||
url.pathname = `/${name}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(): Promise<Client> {
|
||||
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
async function safeQuery<T extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: readonly unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const result = await client.query<T>(text, [...values]);
|
||||
return result.rows;
|
||||
} catch {
|
||||
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
|
||||
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-"));
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModules,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModules, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function freshInstallation(label: string): Promise<string> {
|
||||
databaseCounter += 1;
|
||||
const name =
|
||||
`ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||
const admin = await openAdministrativeClient();
|
||||
try {
|
||||
await safeQuery(admin, `create database "${name}"`);
|
||||
} finally {
|
||||
await admin.end().catch(() => undefined);
|
||||
}
|
||||
trackedDatabases.add(name);
|
||||
const url = databaseUrl(name);
|
||||
await applyCoreMigrations(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/** A delivered message, as the SMTP server actually received it. */
|
||||
interface DeliveredMessage {
|
||||
readonly to: readonly string[];
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
interface MailHarness {
|
||||
readonly port: number;
|
||||
readonly delivered: DeliveredMessage[];
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
const openResources: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of openResources.splice(0).reverse()) {
|
||||
await close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Picks a free port before the API is configured.
|
||||
*
|
||||
* The identity base URL is what the emailed link is built from, so it has to be known *before*
|
||||
* `startApi` binds. Without this the test could only assert the link's shape and would have to
|
||||
* rewrite its host before following it — which is exactly the part worth proving. Reserving and
|
||||
* releasing a port leaves a theoretical race, but nothing else on the loopback interface is
|
||||
* competing for ephemeral ports inside this suite.
|
||||
*/
|
||||
async function reservePort(): Promise<number> {
|
||||
const { createServer } = await import("node:net");
|
||||
const server = createServer();
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function startMailServer(): Promise<MailHarness> {
|
||||
const delivered: DeliveredMessage[] = [];
|
||||
const server = new SMTPServer({
|
||||
authOptional: true,
|
||||
disabledCommands: ["STARTTLS"],
|
||||
onData(stream, session, callback) {
|
||||
let data = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString("utf8");
|
||||
});
|
||||
stream.on("end", () => {
|
||||
delivered.push({
|
||||
data,
|
||||
to: session.envelope.rcptTo.map((recipient) => recipient.address),
|
||||
});
|
||||
callback();
|
||||
});
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.server.address() as AddressInfo;
|
||||
const harness: MailHarness = {
|
||||
close: async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
delivered,
|
||||
port: address.port,
|
||||
};
|
||||
openResources.push(harness.close);
|
||||
return harness;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly url: string;
|
||||
readonly api: RunningApi;
|
||||
readonly origin: string;
|
||||
readonly mail: MailHarness | undefined;
|
||||
readonly notificationFailures: IdentityNotificationFailure[];
|
||||
readonly createIdentity: (input: {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A migrated installation with the API running, optionally with a working SMTP relay.
|
||||
*
|
||||
* `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the
|
||||
* development environment; production configuration refuses both, which `identity-config` and
|
||||
* `mail-contract` assert separately.
|
||||
*/
|
||||
async function installation(
|
||||
label: string,
|
||||
options: { readonly mail?: boolean } = {},
|
||||
): Promise<Installation> {
|
||||
const url = await freshInstallation(label);
|
||||
const mail = options.mail === false ? undefined : await startMailServer();
|
||||
const notificationFailures: IdentityNotificationFailure[] = [];
|
||||
const identity = createIdentityDrizzleDatabase(url, {});
|
||||
openResources.push(async () => {
|
||||
await identity.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
const apiPort = await reservePort();
|
||||
const identityBaseUrl = `http://127.0.0.1:${apiPort}`;
|
||||
const config = createApiRuntimeConfig(
|
||||
{
|
||||
databaseUrl: url,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
dashboardOrigin,
|
||||
publicSignupEnabled: false,
|
||||
secret: integrationSecret,
|
||||
trustedOrigins: [dashboardOrigin],
|
||||
},
|
||||
...(mail
|
||||
? {
|
||||
mail: {
|
||||
fromAddress: "[email protected]",
|
||||
fromName: "Ekklesia",
|
||||
host: "127.0.0.1",
|
||||
port: String(mail.port),
|
||||
security: "plaintext",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
port: apiPort,
|
||||
},
|
||||
{ environment: "development" },
|
||||
);
|
||||
const api = await startApi(config, {
|
||||
rateLimitOverride: { max: 1000, window: 1 },
|
||||
reportNotificationFailure: (failure) => notificationFailures.push(failure),
|
||||
});
|
||||
openResources.push(async () => {
|
||||
await api.shutdown().catch(() => undefined);
|
||||
});
|
||||
|
||||
const address = api.server.address();
|
||||
if (!address || typeof address !== "object" || address.port !== apiPort) {
|
||||
throw new IntegrationHarnessError("The API integration server did not bind the reserved port.");
|
||||
}
|
||||
return {
|
||||
api,
|
||||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||
mail,
|
||||
notificationFailures,
|
||||
origin: identityBaseUrl,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async function post(
|
||||
origin: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
headers: Readonly<Record<string, string>> = {},
|
||||
): Promise<Response> {
|
||||
return await fetch(`${origin}${path}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
}
|
||||
|
||||
function firstCookiePair(response: Response): string | undefined {
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim();
|
||||
}
|
||||
|
||||
async function signIn(
|
||||
origin: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> {
|
||||
const response = await post(origin, "/api/auth/sign-in/email", { email, password });
|
||||
return { cookiePair: firstCookiePair(response), response };
|
||||
}
|
||||
|
||||
async function principalKind(origin: string, cookiePair: string | undefined): Promise<string> {
|
||||
const response = await fetch(`${origin}/api/v1/principal`, {
|
||||
headers: cookiePair ? { cookie: cookiePair } : {},
|
||||
});
|
||||
const body = (await response.json()) as { readonly kind?: unknown };
|
||||
return String(body.kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a test reads a token, and only ever out of a message this suite's own SMTP server
|
||||
* received. Nothing in the platform exposes one.
|
||||
*
|
||||
* Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by
|
||||
* the encoder, and reassembling it here is what makes the extracted link the one a mail client would
|
||||
* actually resolve.
|
||||
*/
|
||||
function extractLink(message: DeliveredMessage, contains: string): URL {
|
||||
const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", "");
|
||||
const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
);
|
||||
const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains));
|
||||
if (!match) {
|
||||
throw new IntegrationHarnessError(`No ${contains} link was delivered.`);
|
||||
}
|
||||
return new URL(match);
|
||||
}
|
||||
|
||||
function lastMessage(mail: MailHarness): DeliveredMessage {
|
||||
const message = mail.delivered.at(-1);
|
||||
if (!message) {
|
||||
throw new IntegrationHarnessError("No message was delivered.");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||
const client = new Client({ connectionString: url });
|
||||
await client.connect();
|
||||
try {
|
||||
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||
client,
|
||||
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||
[email.toLowerCase()],
|
||||
);
|
||||
return rows[0]?.email_verified;
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An account that has completed the real verification round trip.
|
||||
*
|
||||
* Recovery is only meaningful for a verified account, and building one through the actual flow —
|
||||
* rather than by writing `email_verified` directly — keeps every recovery test standing on a
|
||||
* verification path that genuinely works.
|
||||
*/
|
||||
async function verifiedAccountFor(install: Installation, email: string): Promise<void> {
|
||||
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||
await signIn(install.origin, email, initialPassword);
|
||||
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||
await fetch(link, { redirect: "manual" });
|
||||
(install.mail as MailHarness).delivered.length = 0;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||
if (!admin) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (const name of trackedDatabases) {
|
||||
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await admin.end().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Forges a verification token that is structurally valid but already expired.
|
||||
*
|
||||
* Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned
|
||||
* release) — not a database row — so an expired one can be produced here from the same secret this
|
||||
* suite configured, with no library internals and no waiting an hour. This is the only place any
|
||||
* test constructs a token rather than reading one out of a delivered message.
|
||||
*/
|
||||
function forgeExpiredVerificationToken(email: string): string {
|
||||
const encode = (value: object): string =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const issued = Math.floor(Date.now() / 1000) - 7200;
|
||||
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
|
||||
email: email.toLowerCase(),
|
||||
exp: issued + 3600,
|
||||
iat: issued,
|
||||
})}`;
|
||||
const signature = createHmac("sha256", integrationSecret)
|
||||
.update(signingInput)
|
||||
.digest("base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
describe("email verification, end to end", () => {
|
||||
it("takes a new human account from unverified to signed in through a real emailed link", async () => {
|
||||
const install = await installation("flow");
|
||||
const email = "[email protected]";
|
||||
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||
|
||||
// 1. Unverified. A correct password does not produce a session — the identity system refuses
|
||||
// before creating one, so there is no half-authenticated state anywhere (ADR 0038).
|
||||
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||
const refused = await signIn(install.origin, email, initialPassword);
|
||||
expect(refused.response.status).toBe(403);
|
||||
expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||
expect(refused.cookiePair).toBeUndefined();
|
||||
expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous");
|
||||
|
||||
// 2. That attempt delivered a verification message through the real SMTP provider.
|
||||
const mail = install.mail as MailHarness;
|
||||
expect(mail.delivered).toHaveLength(1);
|
||||
expect(lastMessage(mail).to).toEqual([email]);
|
||||
expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address");
|
||||
|
||||
// 3. Follow the link exactly as it was delivered. Nothing rewrites it.
|
||||
const link = extractLink(lastMessage(mail), "/verify-email");
|
||||
expect(link.origin).toBe(install.origin);
|
||||
const verified = await fetch(link, { redirect: "manual" });
|
||||
|
||||
// 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the
|
||||
// API origin, and never to anything a request supplied.
|
||||
expect(verified.status).toBe(302);
|
||||
const landing = new URL(verified.headers.get("location") ?? "");
|
||||
expect(landing.origin).toBe(dashboardOrigin);
|
||||
expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email");
|
||||
expect(landing.searchParams.get("error")).toBeNull();
|
||||
// No session was minted by following an emailed link.
|
||||
expect(verified.headers.get("set-cookie")).toBeNull();
|
||||
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||
|
||||
// 5. The same credentials now work, and the principal is a fully authenticated human.
|
||||
const accepted = await signIn(install.origin, email, initialPassword);
|
||||
expect(accepted.response.status).toBe(200);
|
||||
expect(accepted.cookiePair).toBeDefined();
|
||||
expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated");
|
||||
});
|
||||
|
||||
it("refuses a malformed token and an expired token, each with its own reason", async () => {
|
||||
const install = await installation("bad-token");
|
||||
const email = "[email protected]";
|
||||
await install.createIdentity({ email, name: "Expired", password: initialPassword });
|
||||
const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`);
|
||||
|
||||
for (const [token, reason] of [
|
||||
["not-a-jwt", "INVALID_TOKEN"],
|
||||
[forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"],
|
||||
] as const) {
|
||||
const response = await fetch(
|
||||
`${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(302);
|
||||
const landing = new URL(response.headers.get("location") ?? "");
|
||||
// The failure still lands on the host's own screen rather than a raw JSON error page.
|
||||
expect(landing.origin).toBe(dashboardOrigin);
|
||||
expect(landing.searchParams.get("error")).toBe(reason);
|
||||
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent when a link is followed twice, because the token is a signed claim", async () => {
|
||||
const install = await installation("replay");
|
||||
const email = "[email protected]";
|
||||
await install.createIdentity({ email, name: "Replay", password: initialPassword });
|
||||
await signIn(install.origin, email, initialPassword);
|
||||
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||
|
||||
const first = await fetch(link, { redirect: "manual" });
|
||||
const second = await fetch(link, { redirect: "manual" });
|
||||
|
||||
// Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed
|
||||
// database row, so it is **not** single-use — it is valid until it expires. Replaying it is a
|
||||
// no-op on an already-verified address and mints no session, which is what makes that safe.
|
||||
// Mail scanners and prefetchers dereference links, so idempotence is a requirement here.
|
||||
expect(first.status).toBe(302);
|
||||
expect(second.status).toBe(302);
|
||||
expect(second.headers.get("set-cookie")).toBeNull();
|
||||
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||
});
|
||||
|
||||
it("resends on request without revealing whether the address has an account", async () => {
|
||||
const install = await installation("resend");
|
||||
const email = "[email protected]";
|
||||
await install.createIdentity({ email, name: "Resend", password: initialPassword });
|
||||
const mail = install.mail as MailHarness;
|
||||
|
||||
const known = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||
const unknown = await post(install.origin, "/api/auth/send-verification-email", {
|
||||
email: "[email protected]",
|
||||
});
|
||||
const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", {
|
||||
email: "[email protected]",
|
||||
});
|
||||
|
||||
// Identical answers. The only difference anywhere is that a message was delivered, which the
|
||||
// requester cannot observe.
|
||||
expect(known.status).toBe(200);
|
||||
expect(unknown.status).toBe(200);
|
||||
expect(verifiedAlready.status).toBe(200);
|
||||
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password recovery, end to end", () => {
|
||||
it("replaces the password through a real emailed link and invalidates the old one", async () => {
|
||||
const install = await installation("reset");
|
||||
const email = "[email protected]";
|
||||
await verifiedAccountFor(install, email);
|
||||
const mail = install.mail as MailHarness;
|
||||
|
||||
const requested = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||
expect(requested.status).toBe(200);
|
||||
expect(mail.delivered).toHaveLength(1);
|
||||
expect(lastMessage(mail).to).toEqual([email]);
|
||||
expect(lastMessage(mail).data).toContain("Reset your Ekklesia password");
|
||||
|
||||
// Follow the emailed link. Better Auth validates the token and hands the browser on to the
|
||||
// Dashboard landing with it attached — which is where the host's reset form picks it up.
|
||||
const link = extractLink(lastMessage(mail), "/reset-password/");
|
||||
expect(link.origin).toBe(install.origin);
|
||||
const handoff = await fetch(link, { redirect: "manual" });
|
||||
expect(handoff.status).toBe(302);
|
||||
const landing = new URL(handoff.headers.get("location") ?? "");
|
||||
expect(landing.origin).toBe(dashboardOrigin);
|
||||
expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||
const token = landing.searchParams.get("token") ?? "";
|
||||
expect(token).not.toBe("");
|
||||
|
||||
const completed = await post(install.origin, "/api/auth/reset-password", {
|
||||
newPassword: replacementPassword,
|
||||
token,
|
||||
});
|
||||
expect(completed.status).toBe(200);
|
||||
|
||||
const oldCredential = await signIn(install.origin, email, initialPassword);
|
||||
expect(oldCredential.response.status).toBe(401);
|
||||
const newCredential = await signIn(install.origin, email, replacementPassword);
|
||||
expect(newCredential.response.status).toBe(200);
|
||||
expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated");
|
||||
});
|
||||
|
||||
it("consumes the reset token, so a captured link cannot be replayed", async () => {
|
||||
const install = await installation("reset-replay");
|
||||
const email = "[email protected]";
|
||||
await verifiedAccountFor(install, email);
|
||||
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||
const handoff = await fetch(link, { redirect: "manual" });
|
||||
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||
|
||||
const first = await post(install.origin, "/api/auth/reset-password", {
|
||||
newPassword: replacementPassword,
|
||||
token,
|
||||
});
|
||||
const second = await post(install.origin, "/api/auth/reset-password", {
|
||||
newPassword: "yet another different passphrase",
|
||||
token,
|
||||
});
|
||||
|
||||
// Unlike the verification token, this one is a database row Better Auth consumes.
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(400);
|
||||
expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" });
|
||||
expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("signs out every existing session for that account", async () => {
|
||||
const install = await installation("reset-sessions");
|
||||
const email = "[email protected]";
|
||||
await verifiedAccountFor(install, email);
|
||||
const before = await signIn(install.origin, email, initialPassword);
|
||||
expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated");
|
||||
|
||||
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||
const handoff = await fetch(link, { redirect: "manual" });
|
||||
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||
await post(install.origin, "/api/auth/reset-password", {
|
||||
newPassword: replacementPassword,
|
||||
token,
|
||||
});
|
||||
|
||||
// The whole point of recovery is that it works when the credential is already compromised, so
|
||||
// an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`).
|
||||
expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous");
|
||||
});
|
||||
|
||||
it("enforces the identity system's own password policy, with no reset-specific rule", async () => {
|
||||
const install = await installation("reset-policy");
|
||||
const email = "[email protected]";
|
||||
await verifiedAccountFor(install, email);
|
||||
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||
const handoff = await fetch(link, { redirect: "manual" });
|
||||
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||
|
||||
const tooShort = await post(install.origin, "/api/auth/reset-password", {
|
||||
newPassword: "short",
|
||||
token,
|
||||
});
|
||||
|
||||
expect(tooShort.status).toBe(400);
|
||||
expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" });
|
||||
// The old password still works: a rejected reset changes nothing.
|
||||
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("answers a request for an address with no account exactly as it answers a real one", async () => {
|
||||
const install = await installation("enumeration");
|
||||
const email = "[email protected]";
|
||||
await verifiedAccountFor(install, email);
|
||||
const mail = install.mail as MailHarness;
|
||||
|
||||
const known = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||
const unknown = await post(install.origin, "/api/auth/request-password-reset", {
|
||||
email: "[email protected]",
|
||||
});
|
||||
|
||||
expect(known.status).toBe(unknown.status);
|
||||
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||
expect(JSON.stringify(await unknown.json())).not.toContain("stranger");
|
||||
// Only the real account received anything.
|
||||
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery when the installation has no outbound email", () => {
|
||||
it("answers identically and records the operational failure out of band", async () => {
|
||||
const install = await installation("no-mail", { mail: false });
|
||||
const email = "[email protected]";
|
||||
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||
|
||||
const reset = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||
const resend = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||
|
||||
// The user-facing answer must not change: "we could not send it" for a real address and a
|
||||
// generic success for an unknown one is an enumeration oracle that appears when SMTP breaks.
|
||||
expect(reset.status).toBe(200);
|
||||
expect(resend.status).toBe(200);
|
||||
// The operator still learns about it, with a sanitized code and no address or token.
|
||||
expect(install.notificationFailures).toEqual([
|
||||
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
|
||||
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
|
||||
]);
|
||||
expect(JSON.stringify(install.notificationFailures)).not.toContain("[email protected]");
|
||||
});
|
||||
|
||||
it("still starts, serves the API, and refuses an unverified sign-in", async () => {
|
||||
const install = await installation("no-mail-runs", { mail: false });
|
||||
const email = "[email protected]";
|
||||
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||
|
||||
// Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification
|
||||
// policy — which is exactly why the initial owner is created already verified.
|
||||
expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200);
|
||||
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery cannot be turned into a redirect or an account factory", () => {
|
||||
it("refuses an attacker-supplied reset destination", async () => {
|
||||
const install = await installation("redirect");
|
||||
const email = "[email protected]";
|
||||
await install.createIdentity({ email, name: "Redirect", password: initialPassword });
|
||||
|
||||
const external = await post(install.origin, "/api/auth/request-password-reset", {
|
||||
email,
|
||||
redirectTo: "https://evil.example.test/steal",
|
||||
});
|
||||
|
||||
expect(external.status).toBe(403);
|
||||
expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" });
|
||||
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses an attacker-supplied verification destination", async () => {
|
||||
const install = await installation("redirect-verify");
|
||||
|
||||
for (const callbackURL of [
|
||||
"https://evil.example.test",
|
||||
"//evil.example.test",
|
||||
"/\\evil.example.test",
|
||||
"javascript:alert(1)",
|
||||
]) {
|
||||
const response = await fetch(
|
||||
`${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
{ redirect: "manual" },
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => {
|
||||
const install = await installation("redirect-ignored");
|
||||
const email = "[email protected]";
|
||||
await verifiedAccountFor(install, email);
|
||||
|
||||
// A *trusted* origin passes Better Auth's own check, so this is the case where the emailed
|
||||
// link's destination would otherwise be caller-chosen. It is not: the message is built from
|
||||
// configuration and the supplied value never reaches it.
|
||||
await post(install.origin, "/api/auth/request-password-reset", {
|
||||
email,
|
||||
redirectTo: `${dashboardOrigin}/somewhere-else`,
|
||||
});
|
||||
|
||||
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||
const callback = new URL(link.searchParams.get("callbackURL") ?? "");
|
||||
expect(callback.origin).toBe(dashboardOrigin);
|
||||
expect(callback.pathname).toBe("/");
|
||||
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||
expect(link.href).not.toContain("somewhere-else");
|
||||
});
|
||||
|
||||
it("keeps public signup disabled now that verification exists", async () => {
|
||||
const install = await installation("signup");
|
||||
|
||||
const response = await post(install.origin, "/api/auth/sign-up/email", {
|
||||
email: "[email protected]",
|
||||
name: "Intruder",
|
||||
password: initialPassword,
|
||||
});
|
||||
|
||||
// Email verification is not a licence to self-register. Future accounts arrive through
|
||||
// deliberate administration, which this pass does not build.
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" });
|
||||
expect(await emailVerifiedFlag(install.url, "[email protected]")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed devices are untouched by human email identity", () => {
|
||||
it("leaves a paired device intact across a human password reset", async () => {
|
||||
const install = await installation("devices");
|
||||
const email = "[email protected]";
|
||||
await verifiedAccountFor(install, email);
|
||||
|
||||
// A device is a row with a credential digest and no email, no password, and no verification
|
||||
// state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove
|
||||
// something this test is not about.
|
||||
const client = new Client({ connectionString: install.url });
|
||||
await client.connect();
|
||||
try {
|
||||
await safeQuery(
|
||||
client,
|
||||
'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)',
|
||||
["Sanctuary display", "a".repeat(64)],
|
||||
);
|
||||
|
||||
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||
const handoff = await fetch(link, { redirect: "manual" });
|
||||
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||
await post(install.origin, "/api/auth/reset-password", {
|
||||
newPassword: replacementPassword,
|
||||
token,
|
||||
});
|
||||
|
||||
const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>(
|
||||
client,
|
||||
'select "credential_hash", "revoked_at" from "ekklesia_core_device"',
|
||||
);
|
||||
expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]);
|
||||
|
||||
// The device table has no email or verification concept for a human flow to reach.
|
||||
const columns = await safeQuery<{ column_name: string }>(
|
||||
client,
|
||||
"select column_name from information_schema.columns where table_name = 'ekklesia_core_device'",
|
||||
);
|
||||
const names = columns.map((column) => column.column_name).join(" ");
|
||||
expect(names).not.toContain("email");
|
||||
expect(names).not.toContain("verified");
|
||||
expect(names).not.toContain("password");
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("sends a device no mail, because a device has no address to send to", async () => {
|
||||
const install = await installation("devices-mail");
|
||||
|
||||
await post(install.origin, "/api/auth/request-password-reset", {
|
||||
email: "[email protected]",
|
||||
});
|
||||
|
||||
// Nothing about device identity participates in either flow.
|
||||
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||
expect(install.notificationFailures).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,881 @@
|
||||
import {
|
||||
createAuthenticatedPrincipal,
|
||||
parseEkklesiaUserId,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
AuthorizationStoreError,
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
} from "@ekklesia/core/authorization";
|
||||
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";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const manager = createAuthenticatedPrincipal("manager_1");
|
||||
const subjectUserId = parseEkklesiaUserId("subject_1");
|
||||
// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject
|
||||
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
|
||||
const permission = parsePermissionId("calendar.events.read");
|
||||
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
|
||||
// `store.createGrant`/`store.revokeGrant` can do so. This suite exercises `createGrantsRoutes` in
|
||||
// isolation and intentionally injects `CORE_PERMISSION_REGISTRY` directly rather than a composed
|
||||
// platform registry — the routes have no notion of "Core" versus "module" permissions, they only
|
||||
// ever consult whichever registry they are given. Coverage proving a module-declared permission
|
||||
// works through the exact same route code lives in
|
||||
// `apps/api/tests/authorization-middleware.test.ts` (unit) and the "installed module permission
|
||||
// end-to-end" scenario in `apps/api/tests/api-http.integration.ts` (live).
|
||||
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
|
||||
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(manager),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeStore(overrides: Partial<AuthorizationStore> = {}): 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(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(options: {
|
||||
readonly allowed?: boolean;
|
||||
readonly now?: () => Date;
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly store?: AuthorizationStore;
|
||||
}) {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: options.allowed ?? true }),
|
||||
};
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const store = options.store ?? fakeStore();
|
||||
const app = new Hono();
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
registry: CORE_PERMISSION_REGISTRY,
|
||||
runtime,
|
||||
store,
|
||||
trustedOrigins: new Set([trustedOrigin]),
|
||||
}),
|
||||
);
|
||||
return { app, evaluator, runtime, store };
|
||||
}
|
||||
|
||||
describe("GET /api/v1/grants", () => {
|
||||
it("denies an anonymous or non-manager authenticated caller (evaluator says no)", async () => {
|
||||
const { app, evaluator } = buildApp({ allowed: false });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseline: "restricted" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a caller the evaluator authorizes — whether by platform ownership or a delegated grants.manage grant, the route cannot tell and does not need to (see core/tests/authorization-evaluator.test.ts for that distinction)", async () => {
|
||||
const grants = [
|
||||
{
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
id: grantId,
|
||||
permission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
},
|
||||
];
|
||||
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[]; nextCursor: string | null };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.grants).toHaveLength(1);
|
||||
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 () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants?userId=");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_LIST_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/grants", () => {
|
||||
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
|
||||
const { app, evaluator } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an untrusted Origin header the same way", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("denies a caller the evaluator does not authorize", async () => {
|
||||
const { app, store } = buildApp({ allowed: false });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates an explicit grant, deriving sourceKind and actor server-side from the authenticated principal, never from the request body", async () => {
|
||||
const createdGrant = {
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
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 app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await response.json()) as { grant: { id: string } };
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.grant.id).toBe(grantId);
|
||||
expect(store.createGrant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actor: { type: "user", userId: manager.userId },
|
||||
permission: registeredPermission,
|
||||
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 () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"actor",
|
||||
"owner",
|
||||
"isOwner",
|
||||
"sourceKind",
|
||||
"sourceRef",
|
||||
"id",
|
||||
"grantId",
|
||||
"createdAt",
|
||||
"revokedAt",
|
||||
"createdByActorId",
|
||||
"effectivePermissions",
|
||||
])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a malformed permission, subject id, scope, or expiresAt", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const responses = await Promise.all(
|
||||
[
|
||||
{ permission: "not-namespaced", subjectUserId },
|
||||
{ permission, subjectUserId: "" },
|
||||
{ permission, scope: "", subjectUserId },
|
||||
{ expiresAt: "not-a-date", permission, subjectUserId },
|
||||
].map((body) =>
|
||||
app.request("/api/v1/grants", {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
createGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/grants/:grantId/revoke", () => {
|
||||
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
|
||||
const { app, evaluator } = buildApp({});
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => {
|
||||
const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ success: true });
|
||||
expect(store.revokeGrant).toHaveBeenCalledWith({
|
||||
actor: { type: "user", userId: manager.userId },
|
||||
grantId,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => {
|
||||
// The store itself enforces this by treating a sourceKind mismatch exactly like a missing
|
||||
// grant; this test proves the route always asks for that constraint, and correctly surfaces
|
||||
// the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong
|
||||
// kind.
|
||||
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).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sourceKind: "explicit" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed grant id", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants/%20/revoke", {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
revokeGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
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,375 @@
|
||||
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 { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { 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(),
|
||||
account: createInMemoryAccountStore(),
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,806 @@
|
||||
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 { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { 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,
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,813 @@
|
||||
import { randomUUID } 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 {
|
||||
applyModuleMigrations,
|
||||
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 { type RunningApi, startApi } from "../src/server.js";
|
||||
|
||||
/**
|
||||
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
|
||||
* (`core/tests/fixtures/module-database-integration/db-facade/`) whose lifecycle hooks and
|
||||
* permissioned HTTP handlers perform real typed Drizzle operations — insert, select, update,
|
||||
* transaction, execute — against its own migrated table, exclusively through the facade the
|
||||
* platform hands it. Follows the exact harness conventions `apps/api/tests/api-http.integration.ts`
|
||||
* and `core/tests/postgres-migration.integration.ts` already establish (self-contained per-file
|
||||
* helpers, one disposable PostgreSQL database per scenario, `describe.sequential` blocks sharing a
|
||||
* running API across related assertions) rather than introducing a second integration-test
|
||||
* framework or Docker orchestration path.
|
||||
*
|
||||
* The fixture module is committed under `core/tests/fixtures/...`, not `apps/api/tests/fixtures/`,
|
||||
* for one concrete reason: its schema imports `drizzle-orm/pg-core` and `@ekklesia/contracts`,
|
||||
* neither of which `@ekklesia/api` declares as a direct dependency (pnpm's strict linker will not
|
||||
* resolve them from anywhere under `apps/api/`), while `@ekklesia/core` already depends on both
|
||||
* directly, so any file physically located under `core/`'s directory tree resolves them correctly
|
||||
* regardless of which package's test actually points discovery at it. It is never registered as a
|
||||
* pnpm workspace package (it matches none of `pnpm-workspace.yaml`'s glob patterns), never sits in
|
||||
* the repository's live `modules/` directory, and is only ever discovered when this file explicitly
|
||||
* passes its directory as `modulesDirectoryOverride` — normal production startup, Doctor, and
|
||||
* `pnpm modules:validate` never see it.
|
||||
*/
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const thisDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const coreMigrationsDirectory = path.join(thisDirectory, "..", "..", "..", "core", "drizzle");
|
||||
const fixtureModulesDirectory = path.join(
|
||||
thisDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"core",
|
||||
"tests",
|
||||
"fixtures",
|
||||
"module-database-integration",
|
||||
);
|
||||
|
||||
const RECORDS_TABLE = "db_facade_records";
|
||||
const RECORDER_GLOBAL_KEY = "ekklesia.integration.db-facade.recorder";
|
||||
// Must match `STARTUP_RECORD_ID` in the fixture's `server/index.js` exactly.
|
||||
const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
const identityBaseUrl = "https://identity.example.test";
|
||||
const dashboardOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The module database integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The module database integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
let databaseCounter = 0;
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 14);
|
||||
databaseCounter += 1;
|
||||
return `ekk_dbf_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure remains static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The module database integration connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
const trackedDatabases = new Set<string>();
|
||||
|
||||
async function createDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function activeConnectionCount(databaseName: string): Promise<number> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
"SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1",
|
||||
[databaseName],
|
||||
);
|
||||
return Number(rows[0]?.count ?? "0");
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
|
||||
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
|
||||
const SHUTDOWN_SETTLE_MS = 3_000;
|
||||
|
||||
/**
|
||||
* The connections left on `databaseName` once a shutdown has had a moment to land.
|
||||
*
|
||||
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
|
||||
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
|
||||
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
|
||||
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
|
||||
*/
|
||||
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
|
||||
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
|
||||
for (;;) {
|
||||
const count = await activeConnectionCount(databaseName);
|
||||
if (count === 0 || Date.now() >= deadline) {
|
||||
return count;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
/** Applies only Core's own migrations, against an empty modules directory — the module fixture's
|
||||
* migration is deliberately absent, used only by the "no auto-migration" negative scenario below. */
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(
|
||||
path.join(tmpdir(), "ekklesia-db-facade-no-modules-"),
|
||||
);
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
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",
|
||||
"core:0008_user_preferences",
|
||||
"core:0009_platform_display_pairing",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies Core's migrations plus the `db-facade` fixture module's own migration — explicitly,
|
||||
* before any module runtime exists — proving the platform never infers or auto-generates this
|
||||
* table. Pass-1's committed core migrations and checksums must not have drifted while this pass
|
||||
* was built. */
|
||||
async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: fixtureModulesDirectory,
|
||||
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",
|
||||
"core:0008_user_preferences",
|
||||
"core:0009_platform_display_pairing",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
);
|
||||
expect(aggregate.migrations[1]?.checksum).toBe(
|
||||
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
||||
);
|
||||
expect(aggregate.migrations[2]?.checksum).toBe(
|
||||
"63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036",
|
||||
);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
}
|
||||
|
||||
async function startMigratedApi(
|
||||
url: string,
|
||||
modulesDirectoryOverride?: string,
|
||||
): Promise<RunningApi> {
|
||||
const config = createApiRuntimeConfig(
|
||||
{
|
||||
databaseUrl: url,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
publicSignupEnabled: true,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
// 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 },
|
||||
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
|
||||
});
|
||||
}
|
||||
|
||||
function apiOrigin(running: RunningApi): string {
|
||||
const address = running.server.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw harnessError("The API integration server has no bound address.");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
interface SignUpResult {
|
||||
readonly userId: string;
|
||||
}
|
||||
|
||||
async function signUp(origin: string, email: string, password: string): Promise<SignUpResult> {
|
||||
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||
body: JSON.stringify({ email, name: "Integration Test User", password }),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await response.json()) as { user: { id: string } };
|
||||
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 }),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
if (!setCookie) {
|
||||
throw harnessError("Sign-in did not return a session cookie.");
|
||||
}
|
||||
return setCookie.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
interface RecordedEvent {
|
||||
readonly type: "register" | "start" | "stop" | "handler";
|
||||
readonly services?: { readonly database: unknown };
|
||||
readonly route?: string;
|
||||
readonly contextKeys?: readonly string[];
|
||||
}
|
||||
|
||||
function globalRecord(): Record<string, unknown> {
|
||||
return globalThis as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resetRecorder(): void {
|
||||
globalRecord()[RECORDER_GLOBAL_KEY] = [];
|
||||
}
|
||||
|
||||
function readRecorder(): RecordedEvent[] {
|
||||
return (globalRecord()[RECORDER_GLOBAL_KEY] as RecordedEvent[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
async function tableExists(url: string): Promise<boolean> {
|
||||
const admin = await openAdministrativeClient(url);
|
||||
try {
|
||||
const rows = await safeQuery<{ exists: boolean }>(
|
||||
admin,
|
||||
"select exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = $1) as exists",
|
||||
[RECORDS_TABLE],
|
||||
);
|
||||
return rows[0]?.exists ?? false;
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecordRow(
|
||||
url: string,
|
||||
id: string,
|
||||
): Promise<{ id: string; value: string } | undefined> {
|
||||
const admin = await openAdministrativeClient(url);
|
||||
try {
|
||||
const rows = await safeQuery<{ id: string; value: string }>(
|
||||
admin,
|
||||
`select id, value from "${RECORDS_TABLE}" where id = $1`,
|
||||
[id],
|
||||
);
|
||||
return rows[0];
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordExists(url: string, id: string): Promise<boolean> {
|
||||
return (await fetchRecordRow(url, id)) !== undefined;
|
||||
}
|
||||
|
||||
async function migrationHistoryCount(url: string, identity: string): Promise<number> {
|
||||
const admin = await openAdministrativeClient(url);
|
||||
try {
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
`select count(*)::text as count from "ekklesia_core_migrations" where identity = $1`,
|
||||
[identity],
|
||||
);
|
||||
return Number(rows[0]?.count ?? "0");
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
delete globalRecord()[RECORDER_GLOBAL_KEY];
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("live API — module database explicit migration ownership", () => {
|
||||
it("creates no table merely by applying core migrations or constructing the pool and facade — only an explicit module migration does", async () => {
|
||||
const database = await createDisposableDatabase("mig_absent");
|
||||
try {
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
|
||||
await applyCoreMigrations(database.url);
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
|
||||
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);
|
||||
|
||||
await applyMigrationsWithModule(database.url);
|
||||
expect(await tableExists(database.url)).toBe(true);
|
||||
} finally {
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the module's migration exactly once, never regenerating or reapplying it across repeated startups", async () => {
|
||||
const database = await createDisposableDatabase("mig_once");
|
||||
try {
|
||||
await applyMigrationsWithModule(database.url);
|
||||
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
|
||||
|
||||
const first = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||
await first.shutdown();
|
||||
const second = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||
await second.shutdown();
|
||||
|
||||
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
|
||||
expect(await migrationHistoryCount(database.url, "core:0000_identity_core_tables")).toBe(1);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails module startup rather than silently creating the table when the module's own migration was never applied", async () => {
|
||||
const database = await createDisposableDatabase("mig_skip");
|
||||
try {
|
||||
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" }),
|
||||
expect.objectContaining({ code: "MODULE_ROLLBACK_FAILED", moduleId: "db-facade" }),
|
||||
],
|
||||
});
|
||||
|
||||
// No schema inference, no automatic table creation as a side effect of the failed attempt.
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
} finally {
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — module database access through ModuleRuntimeServices", () => {
|
||||
let database: { name: string; url: string };
|
||||
let running: RunningApi;
|
||||
let origin: string;
|
||||
let ownerCookie: string;
|
||||
|
||||
it("starts a freshly migrated API; register receives no services; start writes the startup record through the real facade", async () => {
|
||||
database = await createDisposableDatabase("db_facade");
|
||||
await applyMigrationsWithModule(database.url);
|
||||
resetRecorder();
|
||||
|
||||
running = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||
origin = apiOrigin(running);
|
||||
|
||||
const events = readRecorder();
|
||||
const registerEvents = events.filter((event) => event.type === "register");
|
||||
const startEvents = events.filter((event) => event.type === "start");
|
||||
expect(registerEvents).toHaveLength(1);
|
||||
expect(registerEvents[0]?.services).toBeUndefined();
|
||||
expect(startEvents).toHaveLength(1);
|
||||
expect(startEvents[0]?.services?.database).toBeDefined();
|
||||
|
||||
const startupRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
|
||||
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 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" });
|
||||
|
||||
const events = readRecorder();
|
||||
const startEvent = events.find((event) => event.type === "start");
|
||||
const readHandlerEvent = [...events]
|
||||
.reverse()
|
||||
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||
// The complete handler-context surface, asserted exactly so a new field is a deliberate change
|
||||
// here rather than an unnoticed one: `config` (ADR 0029), `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);
|
||||
});
|
||||
|
||||
it("requires a trusted Origin for the module's mutating route, performs a real insert, and a later read observes it", async () => {
|
||||
const id = randomUUID();
|
||||
const noOrigin = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
body: JSON.stringify({ id, value: "no-origin" }),
|
||||
headers: { "content-type": "application/json", cookie: ownerCookie },
|
||||
method: "POST",
|
||||
});
|
||||
expect(noOrigin.status).toBe(403);
|
||||
expect(await noOrigin.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
|
||||
const created = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
body: JSON.stringify({ id, value: "created-value" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(await created.json()).toEqual({ id, value: "created-value" });
|
||||
|
||||
const readBack = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`);
|
||||
expect(readBack.status).toBe(200);
|
||||
expect(await readBack.json()).toEqual({ id, value: "created-value" });
|
||||
|
||||
const patched = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`, {
|
||||
body: JSON.stringify({ value: "patched-value" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
expect(patched.status).toBe(200);
|
||||
expect(await patched.json()).toEqual({ id, value: "patched-value" });
|
||||
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "patched-value" });
|
||||
});
|
||||
|
||||
it("denies the mutating route to an unauthenticated caller, invoking neither the handler nor a database mutation", async () => {
|
||||
const id = randomUUID();
|
||||
const eventsBefore = readRecorder().length;
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
body: JSON.stringify({ id, value: "should-not-persist" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(readRecorder().length).toBe(eventsBefore);
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an untrusted-Origin mutation before principal resolution, invoking neither the handler nor a database mutation", async () => {
|
||||
const id = randomUUID();
|
||||
const eventsBefore = readRecorder().length;
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
body: JSON.stringify({ id, value: "should-not-persist" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: untrustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(readRecorder().length).toBe(eventsBefore);
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps OPTIONS platform-owned: never invokes module code, never mutates the table, and returns correct final CORS/Vary", async () => {
|
||||
const id = randomUUID();
|
||||
const eventsBefore = readRecorder().length;
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
headers: {
|
||||
"access-control-request-method": "POST",
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
||||
const vary = (response.headers.get("vary") ?? "").split(",").map((token) => token.trim());
|
||||
expect(vary.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
||||
expect(readRecorder().length).toBe(eventsBefore);
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot let HEAD bypass the authorized GET chain, and creates no mutation", async () => {
|
||||
const anonymousHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
expect(anonymousHead.status).toBe(403);
|
||||
expect(await anonymousHead.text()).toBe("");
|
||||
|
||||
const authorizedHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||
headers: { cookie: ownerCookie },
|
||||
method: "HEAD",
|
||||
});
|
||||
expect(authorizedHead.status).toBe(200);
|
||||
expect(await authorizedHead.text()).toBe("");
|
||||
|
||||
const countBefore = await countRecordRowsViaGet(origin, ownerCookie);
|
||||
const countAfter = await countRecordRowsViaGet(origin, ownerCookie);
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
|
||||
it("commits a real transaction and persists its result", async () => {
|
||||
const id = randomUUID();
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
|
||||
body: JSON.stringify({ id, value: "committed-value" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.json()).toEqual({ committed: true });
|
||||
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "committed-value" });
|
||||
});
|
||||
|
||||
it("rolls back a real transaction on a unique-constraint failure, leaving no partial mutation", async () => {
|
||||
const id = randomUUID();
|
||||
const conflictId = randomUUID();
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
|
||||
body: JSON.stringify({ id, value: "should-roll-back", conflictId }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toEqual({ committed: false });
|
||||
|
||||
// The whole transaction rolled back atomically — including the first, otherwise-valid insert.
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
expect(await recordExists(database.url, conflictId)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves a module's own sanitized response for a database failure it deliberately catches", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/safe-boom`);
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toEqual({ code: "BOOM_HANDLED" });
|
||||
});
|
||||
|
||||
it("sanitizes an uncaught real PostgreSQL failure to the existing MODULE_HANDLER_FAILED boundary, leaking no detail", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/boom`);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(text).toBe(JSON.stringify({ code: "MODULE_HANDLER_FAILED" }));
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
|
||||
const lowered = text.toLowerCase();
|
||||
for (const forbidden of [
|
||||
"division",
|
||||
"sql",
|
||||
"postgres",
|
||||
"22012",
|
||||
"select",
|
||||
"relation",
|
||||
"stack",
|
||||
"node_modules",
|
||||
fixtureModulesDirectory.toLowerCase(),
|
||||
]) {
|
||||
expect(lowered).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("stops cleanly: stop receives the same services and database as start, its write completes before the pool closes, and the API shuts down", async () => {
|
||||
const events = readRecorder();
|
||||
const startEvent = events.find((event) => event.type === "start");
|
||||
|
||||
await running.shutdown();
|
||||
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
|
||||
const eventsAfterShutdown = readRecorder();
|
||||
const stopEvents = eventsAfterShutdown.filter((event) => event.type === "stop");
|
||||
expect(stopEvents).toHaveLength(1);
|
||||
expect(stopEvents[0]?.services?.database).toBe(startEvent?.services?.database);
|
||||
|
||||
// Read via a separate administrative connection, entirely independent of the API's own
|
||||
// (now-closed) pool — proving the stop write completed and persisted before that pool closed.
|
||||
const stoppedRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
|
||||
expect(stoppedRow).toEqual({ id: STARTUP_RECORD_ID, value: "stopped-by-shutdown" });
|
||||
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
async function countRecordRowsViaGet(origin: string, cookie: string): Promise<number> {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||
headers: { cookie },
|
||||
});
|
||||
const body = (await response.json()) as { count: number };
|
||||
return body.count;
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
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 { 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,
|
||||
DISPLAY_SETUP_PAGE_URL,
|
||||
ModuleDisplayConfigurationError,
|
||||
mountModuleDisplayRoutes,
|
||||
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 { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { 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(),
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the platform display setup page (ADR 0065)", () => {
|
||||
function bare(options: { readonly platformSetup?: boolean; readonly assets?: boolean }) {
|
||||
const app = new Hono();
|
||||
mountModuleDisplayRoutes(app, {
|
||||
evaluator: createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async () => false },
|
||||
platformOwners: { isPlatformOwner: async () => false },
|
||||
}),
|
||||
// No installed module has a display at all.
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
registry: composePlatformPermissionRegistry([]),
|
||||
runtime: {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
},
|
||||
...(options.assets === false ? {} : { assets: displayHostAssets }),
|
||||
...(options.platformSetup === undefined ? {} : { platformSetup: options.platformSetup }),
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
it("serves /display/setup and the display host with zero display modules installed", async () => {
|
||||
const app = bare({ platformSetup: true });
|
||||
|
||||
expect(DISPLAY_SETUP_PAGE_URL).toBe("/display/setup");
|
||||
const page = await app.request(DISPLAY_SETUP_PAGE_URL);
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||
// The same inert, module-agnostic page every display is served.
|
||||
expect(await page.text()).toBe(displayHostAssets.html);
|
||||
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(200);
|
||||
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(200);
|
||||
// Nothing else beneath /display/ exists.
|
||||
expect((await app.request("/display/signage")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("is not served without device support, and device support needs the display host", async () => {
|
||||
expect((await bare({}).request(DISPLAY_SETUP_PAGE_URL)).status).toBe(404);
|
||||
expect(() => bare({ assets: false, platformSetup: true })).toThrow(
|
||||
ModuleDisplayConfigurationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,584 @@
|
||||
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 { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { 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,
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
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,820 @@
|
||||
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,
|
||||
saveHostAccountPreferences,
|
||||
} from "@ekklesia/shell";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { 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;
|
||||
/** People with an account, for the account area's routes (ADR 0062). */
|
||||
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
|
||||
}
|
||||
|
||||
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),
|
||||
account: createInMemoryAccountStore(options.people ?? []),
|
||||
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}`);
|
||||
}
|
||||
// A browser names the Dashboard's origin on every cross-origin request; the shell never sets it.
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Origin", "https://dashboard.example.test");
|
||||
return app.request(url.href, { ...init, headers });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
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("loads the signed-in person's own account, and a saved preference is what the next load sees (ADR 0062)", async () => {
|
||||
const harness = await startHarness({
|
||||
people: [{ email: "[email protected]", name: "Viewer Person", userId: viewerId }],
|
||||
principal: createAuthenticatedPrincipal(viewerId),
|
||||
});
|
||||
try {
|
||||
const first = await bootstrapHost(harness.config);
|
||||
expect(first.account).toEqual({
|
||||
email: "[email protected]",
|
||||
name: "Viewer Person",
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
});
|
||||
|
||||
expect(await saveHostAccountPreferences(harness.config, { appearance: "dark" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "system",
|
||||
});
|
||||
expect(await saveHostAccountPreferences(harness.config, { motion: "reduce" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "reduce",
|
||||
});
|
||||
|
||||
// Another device signing in reads the account, not this one's browser.
|
||||
const next = await bootstrapHost(harness.config);
|
||||
expect(next.account !== "unavailable" && next.account.preferences).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "reduce",
|
||||
});
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
|
||||
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,636 @@
|
||||
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 { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { 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),
|
||||
account: createInMemoryAccountStore(),
|
||||
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,14 @@
|
||||
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",
|
||||
"(() => {})();\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AccountPreferences,
|
||||
type AccountPreferencesUpdate,
|
||||
type AccountStore,
|
||||
AccountStoreError,
|
||||
type AccountSummary,
|
||||
DEFAULT_ACCOUNT_PREFERENCES,
|
||||
parseAccountPreferencesUpdate,
|
||||
} from "@ekklesia/core/account";
|
||||
|
||||
export interface InMemoryAccountPerson {
|
||||
readonly userId: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly preferences?: Partial<AccountPreferences>;
|
||||
}
|
||||
|
||||
export interface InMemoryAccountStore extends AccountStore {
|
||||
/** Every user id a read or write was asked for, in order, so a route test can prove which account
|
||||
* the route touched — the session's, and never one named by the request. */
|
||||
readonly requestedUserIds: readonly string[];
|
||||
/** Makes the next operations fail the way an unreachable database would. */
|
||||
failWith(code: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory `AccountStore` for tests that exercise **routing and authorization**, not
|
||||
* persistence. It reproduces the store's contract — strict update parsing, partial updates, an
|
||||
* unknown person is not found — and nothing a database owns; persistence, the `CHECK`
|
||||
* constraints, isolation across connections, and the cascade are proven against real PostgreSQL in
|
||||
* `core/tests/account-store.integration.ts`.
|
||||
*/
|
||||
export function createInMemoryAccountStore(
|
||||
people: readonly InMemoryAccountPerson[] = [],
|
||||
): InMemoryAccountStore {
|
||||
const records = new Map<string, { name: string; email: string; preferences: AccountPreferences }>(
|
||||
people.map((person) => [
|
||||
person.userId,
|
||||
{
|
||||
email: person.email,
|
||||
name: person.name,
|
||||
preferences: { ...DEFAULT_ACCOUNT_PREFERENCES, ...person.preferences },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const requestedUserIds: string[] = [];
|
||||
let failure: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined;
|
||||
|
||||
return {
|
||||
failWith(code) {
|
||||
failure = code;
|
||||
},
|
||||
async readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined> {
|
||||
requestedUserIds.push(userId);
|
||||
if (failure) {
|
||||
throw new AccountStoreError("ACCOUNT_READ_FAILED");
|
||||
}
|
||||
const record = records.get(userId);
|
||||
return record
|
||||
? { email: record.email, name: record.name, preferences: { ...record.preferences } }
|
||||
: undefined;
|
||||
},
|
||||
requestedUserIds,
|
||||
async updatePreferences(
|
||||
userId: EkklesiaUserId,
|
||||
update: AccountPreferencesUpdate,
|
||||
): Promise<AccountPreferences> {
|
||||
requestedUserIds.push(userId);
|
||||
const parsed = parseAccountPreferencesUpdate(update);
|
||||
if (!parsed) {
|
||||
throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID");
|
||||
}
|
||||
if (failure) {
|
||||
throw new AccountStoreError("ACCOUNT_WRITE_FAILED");
|
||||
}
|
||||
const record = records.get(userId);
|
||||
if (!record) {
|
||||
throw new AccountStoreError("ACCOUNT_NOT_FOUND");
|
||||
}
|
||||
record.preferences = { ...record.preferences, ...parsed };
|
||||
return { ...record.preferences };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/node.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
+4
-1
@@ -19,7 +19,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
"preset": "recommended",
|
||||
"suspicious": {
|
||||
"noAlert": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
||||
@@ -2,7 +2,15 @@ import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./src/identity/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",
|
||||
"./src/account/schema.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
prefix: "index",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE "ekklesia_core_authorization_audit" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"actor_type" text NOT NULL,
|
||||
"actor_id" text,
|
||||
"subject_user_id" text NOT NULL,
|
||||
"permission_id" text,
|
||||
"scope" text,
|
||||
"grant_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_authorization_audit_action_check" CHECK ("ekklesia_core_authorization_audit"."action" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')),
|
||||
CONSTRAINT "ekklesia_core_authorization_audit_actor_type_check" CHECK ("ekklesia_core_authorization_audit"."actor_type" in ('user', 'system')),
|
||||
CONSTRAINT "ekklesia_core_authorization_audit_scope_check" CHECK ("ekklesia_core_authorization_audit"."scope" is null or length("ekklesia_core_authorization_audit"."scope") > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ekklesia_core_grant" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"permission_id" text NOT NULL,
|
||||
"scope" text,
|
||||
"source_kind" text NOT NULL,
|
||||
"source_ref" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_by_actor_type" text NOT NULL,
|
||||
"created_by_actor_id" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"revoked_by_actor_type" text,
|
||||
"revoked_by_actor_id" text,
|
||||
CONSTRAINT "ekklesia_core_grant_source_kind_check" CHECK ("ekklesia_core_grant"."source_kind" in ('explicit', 'relationship')),
|
||||
CONSTRAINT "ekklesia_core_grant_created_actor_type_check" CHECK ("ekklesia_core_grant"."created_by_actor_type" in ('user', 'system')),
|
||||
CONSTRAINT "ekklesia_core_grant_revoked_actor_type_check" CHECK ("ekklesia_core_grant"."revoked_by_actor_type" is null or "ekklesia_core_grant"."revoked_by_actor_type" in ('user', 'system'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ekklesia_core_platform_owner" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"granted_by_actor_type" text NOT NULL,
|
||||
"granted_by_actor_id" text,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"revoked_by_actor_type" text,
|
||||
"revoked_by_actor_id" text,
|
||||
CONSTRAINT "ekklesia_core_platform_owner_granted_actor_type_check" CHECK ("ekklesia_core_platform_owner"."granted_by_actor_type" in ('user', 'system')),
|
||||
CONSTRAINT "ekklesia_core_platform_owner_revoked_actor_type_check" CHECK ("ekklesia_core_platform_owner"."revoked_by_actor_type" is null or "ekklesia_core_platform_owner"."revoked_by_actor_type" in ('user', 'system'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_authorization_audit" ADD CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_grant" ADD CONSTRAINT "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_platform_owner" ADD CONSTRAINT "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_authorization_audit_subject_idx" ON "ekklesia_core_authorization_audit" USING btree ("subject_user_id");--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_grant_user_permission_idx" ON "ekklesia_core_grant" USING btree ("user_id","permission_id");
|
||||
@@ -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,11 @@
|
||||
CREATE TABLE "ekklesia_core_user_preference" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"appearance" text,
|
||||
"motion" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_user_preference_appearance_check" CHECK ("ekklesia_core_user_preference"."appearance" is null or "ekklesia_core_user_preference"."appearance" in ('system', 'light', 'dark')),
|
||||
CONSTRAINT "ekklesia_core_user_preference_motion_check" CHECK ("ekklesia_core_user_preference"."motion" is null or "ekklesia_core_user_preference"."motion" in ('system', 'reduce', 'allow'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_user_preference" ADD CONSTRAINT "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "module_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device_pairing_request" ALTER COLUMN "permission_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_module_check" CHECK (("ekklesia_core_device_pairing_request"."module_id" is null) = ("ekklesia_core_device_pairing_request"."permission_id" is null));
|
||||
@@ -0,0 +1,683 @@
|
||||
{
|
||||
"id": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
|
||||
"prevId": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
|
||||
"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": {
|
||||
"ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_authorization_audit",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["subject_user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,60 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0001_authorization_core_tables",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0008_user_preferences",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0009_platform_display_pairing",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,69 @@
|
||||
"when": 1784668552522,
|
||||
"tag": "0000_identity_core_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1792036365485,
|
||||
"tag": "0008_user_preferences",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1792124723290,
|
||||
"tag": "0009_platform_display_pairing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+44
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -11,10 +12,22 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./account": {
|
||||
"types": "./dist/account/index.d.ts",
|
||||
"import": "./dist/account/index.js"
|
||||
},
|
||||
"./modules": {
|
||||
"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"
|
||||
@@ -22,6 +35,30 @@
|
||||
"./diagnostics": {
|
||||
"types": "./dist/diagnostics/index.d.ts",
|
||||
"import": "./dist/diagnostics/index.js"
|
||||
},
|
||||
"./identity": {
|
||||
"types": "./dist/identity/index.d.ts",
|
||||
"import": "./dist/identity/index.js"
|
||||
},
|
||||
"./authorization": {
|
||||
"types": "./dist/authorization/index.d.ts",
|
||||
"import": "./dist/authorization/index.js"
|
||||
},
|
||||
"./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": {
|
||||
@@ -33,17 +70,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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
import { accountSchema } from "./schema.js";
|
||||
|
||||
export type AccountDrizzleDatabase = NodePgDatabase<typeof accountSchema>;
|
||||
|
||||
/**
|
||||
* A typed Drizzle view over an externally owned pool, exactly like `createDeviceDrizzleView`: it
|
||||
* never constructs, registers an error listener on, or closes a pool.
|
||||
*/
|
||||
export function createAccountDrizzleView(pool: Pool): AccountDrizzleDatabase {
|
||||
return drizzle(pool, { schema: accountSchema });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./database.js";
|
||||
export * from "./preferences.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./store.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user