Compare commits
214
Commits
b38f85f438
..
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
82fe338a8d | ||
|
|
679a5d225c | ||
|
|
df0f20d8af | ||
|
|
02ee3ccd80 | ||
|
|
f89d575dd4 | ||
|
|
5104f2e954 | ||
|
|
262e7b7660 | ||
|
|
831a48187c |
@@ -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.
|
# 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_DB=ekklesia
|
||||||
POSTGRES_USER=ekklesia
|
POSTGRES_USER=ekklesia
|
||||||
POSTGRES_PASSWORD=ekklesia-development-only
|
POSTGRES_PASSWORD=ekklesia-development-only
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
|
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_ACCESS_KEY=ekklesia-development
|
||||||
S3_SECRET_KEY=ekklesia-development-secret
|
S3_SECRET_KEY=ekklesia-development-secret
|
||||||
S3_BUCKET=ekklesia-development
|
|
||||||
S3_ENDPOINT=http://127.0.0.1:8333
|
S3_ENDPOINT=http://127.0.0.1:8333
|
||||||
S3_PORT=8333
|
S3_PORT=8333
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_FORCE_PATH_STYLE=true
|
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
|
*.gif binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
*.woff2 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
|
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:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
@@ -20,6 +32,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
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
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
@@ -41,9 +58,278 @@ jobs:
|
|||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
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:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
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
|
- name: Exercise PostgreSQL and S3 infrastructure
|
||||||
run: bash scripts/smoke-infrastructure.sh
|
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
|
||||||
|
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: Exercise transactional migrations against PostgreSQL
|
||||||
|
run: pnpm test:migrations:integration
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
package-release:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
revision: ${{ steps.release.outputs.revision }}
|
||||||
|
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ADR 0053's package release gate: build the public package train from this commit, build it
|
||||||
|
# again from a second checkout and require byte-identical artifacts, then install exactly those
|
||||||
|
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
|
||||||
|
# packages, and verifies. Nothing is published and no credential exists here. A future publish
|
||||||
|
# job must depend on this job and publish only the artifacts it proved.
|
||||||
|
- name: Build, reproduce, and consume the public package train
|
||||||
|
run: pnpm test:package-release --keep package-train
|
||||||
|
|
||||||
|
- name: Describe the proven release train
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
manifest="$release/ekklesia-package-train.json"
|
||||||
|
{
|
||||||
|
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||||
|
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||||
|
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||||
|
} >>"$GITHUB_OUTPUT"
|
||||||
|
cat "$manifest"
|
||||||
|
|
||||||
|
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
|
||||||
|
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
|
||||||
|
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
|
||||||
|
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
|
||||||
|
- name: Hand the release train on
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# The publication tool, given exactly the artifact the package-release job proved, against a
|
||||||
|
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
|
||||||
|
# credential: the disposable registry's accounts are created and discarded inside the job.
|
||||||
|
package-publication:
|
||||||
|
needs: package-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: package-publication
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Receive the release train the gate proved
|
||||||
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
|
||||||
|
- name: Check it is exactly that train
|
||||||
|
env:
|
||||||
|
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
|
||||||
|
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
|
||||||
|
echo "The received release manifest is not the one the package-release job proved." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
(cd "$release" && sha256sum --check --strict SHA256SUMS)
|
||||||
|
|
||||||
|
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
|
||||||
|
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
image-build:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: image-build
|
||||||
|
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
||||||
|
# has no buildx plugin.
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
# The one generic platform image (ADR 0044, ADR 0048), proved rather than assumed: it contains no
|
||||||
|
# module, no module template, and no module manifest; its module storage mount point is empty;
|
||||||
|
# EKKLESIA_MODULES_DIR names it; and a checkout with a populated modules/ directory builds an
|
||||||
|
# identical runtime file system. Build only — no registry login, no credentials, no push, no tag
|
||||||
|
# anywhere but this runner's local image store. Tagged per run and attempt, and labeled like
|
||||||
|
# every CI stack, so this job's final step removes the images even if a build is killed.
|
||||||
|
- name: Build the generic platform image and prove it holds no modules
|
||||||
|
run: bash scripts/check-image.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
deploy-smoke:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Both topologies (ADR 0045), each on the one generic image: same-origin, the default every
|
||||||
|
# installation gets, and split, which installs a test-only module into module storage, removes
|
||||||
|
# it, and restores it (ADR 0048), and drives a real headless browser across the two origins.
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
topology:
|
||||||
|
- same-origin
|
||||||
|
- split
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
# The reference deployment end to end (ADR 0044): the generic platform image, base + proxy with
|
||||||
|
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
||||||
|
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
|
||||||
|
# database TLS through the proxy. Every secret is generated for this run and discarded with
|
||||||
|
# it; nothing is pushed, published, or logged in to.
|
||||||
|
- name: Smoke-test the reference deployment (${{ matrix.topology }})
|
||||||
|
run: bash scripts/smoke-deploy.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
name: Publish packages
|
||||||
|
|
||||||
|
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
|
||||||
|
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
|
||||||
|
#
|
||||||
|
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
|
||||||
|
# request or push can start it. It needs the version-set commit of a release (`version` must equal
|
||||||
|
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
|
||||||
|
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
|
||||||
|
# readable (ADR 0054); publishing is what this protects.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: The package train version to publish, equal to the platform version on main.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
|
||||||
|
concurrency:
|
||||||
|
group: publish-packages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
# The only registry-specific setting: where the train is published.
|
||||||
|
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package-release:
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
revision: ${{ steps.release.outputs.revision }}
|
||||||
|
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# The same credential-free gate as CI: build, reproduce, and consume the train.
|
||||||
|
- name: Build, reproduce, and consume the public package train
|
||||||
|
run: pnpm test:package-release --keep package-train
|
||||||
|
|
||||||
|
- name: Describe the proven release train
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
manifest="$release/ekklesia-package-train.json"
|
||||||
|
{
|
||||||
|
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||||
|
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||||
|
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||||
|
} >>"$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
|
||||||
|
# name must not start with a dot: the artifact actions leave hidden files out.
|
||||||
|
- name: Hand the release train on
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: package-release
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
# Only for the publication tool itself: nothing is built or installed in this job.
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Receive the release train the gate proved
|
||||||
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
|
||||||
|
# The tool validates the whole train against its manifest, that manifest against the gate's,
|
||||||
|
# its version against the request, and its revision against this commit, before it sends
|
||||||
|
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
|
||||||
|
- name: Publish the exact release train
|
||||||
|
env:
|
||||||
|
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
|
||||||
|
TRAIN_VERSION: ${{ inputs.version }}
|
||||||
|
TRAIN_REVISION: ${{ github.sha }}
|
||||||
|
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||||
|
run: |
|
||||||
|
node scripts/publish-package-train.ts \
|
||||||
|
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
|
||||||
|
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
|
||||||
|
--version "$TRAIN_VERSION" \
|
||||||
|
--revision "$TRAIN_REVISION" \
|
||||||
|
--manifest-sha256 "$MANIFEST_SHA256" \
|
||||||
|
--record publication-record.json \
|
||||||
|
--require-public-read
|
||||||
|
|
||||||
|
- name: Keep the publication record
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train-publication-record
|
||||||
|
path: publication-record.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
+15
@@ -1,4 +1,8 @@
|
|||||||
node_modules/
|
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/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
@@ -13,3 +17,14 @@ generated/*
|
|||||||
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
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/
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for Claude Code (and other coding agents) working in this repository. This file is
|
||||||
|
intentionally short and durable — it points at authoritative sources instead of restating their
|
||||||
|
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:
|
||||||
|
|
||||||
|
- **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 independently versioned product
|
||||||
|
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||||
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||||
|
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||||
|
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||||
|
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||||
|
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||||
|
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||||
|
presentation concern a module does not. They consume public API/client contracts only, never Core
|
||||||
|
or module internals directly.
|
||||||
|
|
||||||
|
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||||
|
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||||
|
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
||||||
|
|
||||||
|
## ADRs are authoritative
|
||||||
|
|
||||||
|
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||||
|
immutable except for status and cross-reference corrections** — do not edit their content to fit a
|
||||||
|
new change. A changed decision gets a new ADR that supersedes the old one. Read the full ADR index
|
||||||
|
(`docs/architecture/decisions/README.md`) and every ADR relevant to the area you are touching
|
||||||
|
before designing a change; do not assume this file's summary of any decision is complete or
|
||||||
|
current.
|
||||||
|
|
||||||
|
## Read before you design
|
||||||
|
|
||||||
|
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)
|
||||||
|
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
||||||
|
- `CONTRIBUTING.md`
|
||||||
|
|
||||||
|
These files change as the platform evolves. **Inspect the actual current repository state
|
||||||
|
(source, tests, package.json scripts, CI workflow) before relying on anything in this file or in
|
||||||
|
your own memory of a prior session** — treat stale assumptions as a bug to catch, not a shortcut.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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
|
||||||
|
test coverage, not just a passing existing suite.
|
||||||
|
- Errors that reach a user, log, CLI, or `pnpm doctor` output must be sanitized: never expose
|
||||||
|
secrets, connection strings, raw SQL, raw driver/exception messages, stack traces, causes, or
|
||||||
|
machine-specific paths. Match the sanitization pattern already used by
|
||||||
|
`core/src/migrations/runner.ts` and `core/src/diagnostics/doctor.ts` rather than inventing a new
|
||||||
|
one.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Canonical verification commands
|
||||||
|
|
||||||
|
Run from the repository root; `package.json` is the source of truth if this list and the file
|
||||||
|
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 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
|
||||||
|
|
||||||
|
If a task would require weakening an existing ADR's guarantee, introducing a second mechanism
|
||||||
|
where one already exists (a second migration runner, a second discovery path, a second config
|
||||||
|
system), or exposing something the boundary checker currently blocks, stop and surface the
|
||||||
|
conflict instead of working around it.
|
||||||
+54
-4
@@ -9,6 +9,16 @@ Before opening a change, run:
|
|||||||
pnpm verify
|
pnpm verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [testing guide](docs/development/testing.md) for the distinction between unit and live
|
||||||
|
evidence and for credential-safe troubleshooting.
|
||||||
|
|
||||||
When adding or changing a module manifest, also run `pnpm modules:validate` directly for focused,
|
When adding or changing a module manifest, also run `pnpm modules:validate` directly for focused,
|
||||||
path-aware discovery errors. It is included in `pnpm verify`.
|
path-aware discovery errors. It is included in `pnpm verify`.
|
||||||
|
|
||||||
@@ -24,10 +34,50 @@ pnpm infrastructure:config
|
|||||||
- Expose supported entry points through package `exports`; do not import another package's
|
- Expose supported entry points through package `exports`; do not import another package's
|
||||||
private source paths.
|
private source paths.
|
||||||
- Use `workspace:` ranges for internal package dependencies.
|
- Use `workspace:` ranges for internal package dependencies.
|
||||||
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other
|
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||||
UI source files.
|
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||||
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a
|
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||||
surface.
|
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.
|
- 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.
|
- 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,47 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/api",
|
||||||
|
"version": "0.2.1",
|
||||||
|
"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/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,507 @@
|
|||||||
|
import {
|
||||||
|
isAuthenticatedPrincipal,
|
||||||
|
type ModuleSecretSource,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
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 { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||||
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
|
import { createDeviceRoutes } from "./device-routes.js";
|
||||||
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
|
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
|
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||||
|
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||||
|
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||||
|
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||||
|
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||||
|
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||||
|
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||||
|
|
||||||
|
export interface CreateApiAppAuthorizationOptions {
|
||||||
|
readonly evaluator: AuthorizationEvaluator;
|
||||||
|
/** 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; route composition rejects an
|
||||||
|
* inconsistent display-bearing registry synchronously. */
|
||||||
|
readonly displayHostAssets?: DisplayHostAssets;
|
||||||
|
/**
|
||||||
|
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
|
||||||
|
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
|
||||||
|
* Required rather than defaulted, like every other module option here — a caller exercising no
|
||||||
|
* modules says so with an empty snapshot, never falls through to an implicit one.
|
||||||
|
*
|
||||||
|
* The snapshot must come from the same boot as `moduleRoutes` and `moduleUi`; that is what makes
|
||||||
|
* "running" an observation of this process rather than a prediction. See
|
||||||
|
* `./module-lifecycle-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly moduleLifecycle: {
|
||||||
|
readonly snapshot: ModuleLifecycleSnapshot;
|
||||||
|
readonly store: ModuleLifecycleStore;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The platform's module configuration resource (ADR 0029): the discovered inventory whose
|
||||||
|
* manifests declare what may be configured, the settings this process resolved at boot (used only
|
||||||
|
* to compute `restartRequired`), the deployment's secret source, and the store.
|
||||||
|
*
|
||||||
|
* Required rather than defaulted, like every other module option here. `secrets` is explicit for
|
||||||
|
* the same reason: a caller that supplies no module secrets says so, and a module requiring one is
|
||||||
|
* then reported `secret_missing` rather than silently starting without it.
|
||||||
|
*/
|
||||||
|
readonly moduleConfiguration: {
|
||||||
|
readonly discoveredModules: readonly DiscoveredModule[];
|
||||||
|
readonly bootSettings: ModuleConfigurationSettings;
|
||||||
|
readonly secrets: ModuleSecretSource;
|
||||||
|
readonly store: ModuleConfigurationStore;
|
||||||
|
/** The per-module frozen snapshots the started runtime is actually using — the identical map
|
||||||
|
* `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks
|
||||||
|
* read the same object rather than two independently resolved values. */
|
||||||
|
readonly snapshots: ModuleConfigurationLookup;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The platform's module object storage (ADR 0030): the per-module scoped facades the runtime
|
||||||
|
* started with, and whether a host provider is configured at all.
|
||||||
|
*
|
||||||
|
* Required rather than defaulted, like every other module option here — a caller exercising no
|
||||||
|
* storage says so with an empty map and `providerConfigured: false`, never falls through to an
|
||||||
|
* implicit one. Nothing here carries an endpoint, a region, or a credential: those never leave
|
||||||
|
* Core's provider.
|
||||||
|
*/
|
||||||
|
readonly moduleStorage: {
|
||||||
|
readonly snapshots: ModuleStorageLookup;
|
||||||
|
readonly providerConfigured: boolean;
|
||||||
|
};
|
||||||
|
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
|
||||||
|
* rather than defaulted, like every other module option here: a caller that exercises no
|
||||||
|
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
|
||||||
|
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||||
|
* ordering, module startup, or any authorization decision. */
|
||||||
|
readonly dashboard: DashboardStore;
|
||||||
|
/**
|
||||||
|
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||||
|
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||||
|
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
|
||||||
|
* `./dashboard-host-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly dashboardHostAssets: DashboardHostAssets;
|
||||||
|
/**
|
||||||
|
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
|
||||||
|
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||||
|
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
|
||||||
|
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly health: ApiHealthOptions;
|
||||||
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||||
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||||
|
* `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 exact literal paths outside `/api/` (ADR 0044). They
|
||||||
|
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||||
|
// `./dashboard-host-routes.js`.
|
||||||
|
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||||
|
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createGrantsRoutes({
|
||||||
|
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 } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.devices) {
|
||||||
|
// Host-owned device identity. Siblings of `/api/v1/modules/...`, reachable by no module, and
|
||||||
|
// mounted only when the deployment actually composed device support.
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createDeviceRoutes({
|
||||||
|
devices: options.devices.store,
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
moduleUi: options.moduleUi,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
runtime,
|
||||||
|
secureCookies: options.devices.secureCookies,
|
||||||
|
trustedOrigins: allowedOrigins,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layered deliberately on top of the generic inventory above rather than merged into it: the
|
||||||
|
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
|
||||||
|
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createDashboardRoutes({
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
moduleUi: options.moduleUi,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
runtime,
|
||||||
|
store: options.dashboard,
|
||||||
|
trustedOrigins: allowedOrigins,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
||||||
|
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
||||||
|
// rather than inside it. See `./module-lifecycle-routes.js`.
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createModuleLifecycleRoutes({
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
runtime,
|
||||||
|
configuration: {
|
||||||
|
secrets: options.moduleConfiguration.secrets,
|
||||||
|
store: options.moduleConfiguration.store,
|
||||||
|
},
|
||||||
|
storage: { providerConfigured: options.moduleStorage.providerConfigured },
|
||||||
|
snapshot: options.moduleLifecycle.snapshot,
|
||||||
|
store: options.moduleLifecycle.store,
|
||||||
|
trustedOrigins: allowedOrigins,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely
|
||||||
|
// to installed modules. See `./module-configuration-routes.js`.
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createModuleConfigurationRoutes({
|
||||||
|
bootSettings: options.moduleConfiguration.bootSettings,
|
||||||
|
discoveredModules: options.moduleConfiguration.discoveredModules,
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
runtime,
|
||||||
|
secrets: options.moduleConfiguration.secrets,
|
||||||
|
store: options.moduleConfiguration.store,
|
||||||
|
trustedOrigins: allowedOrigins,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A sibling platform-owned prefix, never nested under `/api/v1/modules/` where installed modules
|
||||||
|
// own every path. One exact route per runtime-published channel, authorized before subscription.
|
||||||
|
mountModuleRealtimeRoutes(app, {
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
realtime: options.moduleRealtime,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
||||||
|
configurations: options.moduleConfiguration.snapshots,
|
||||||
|
storages: options.moduleStorage.snapshots,
|
||||||
|
evaluator: options.authorization.evaluator,
|
||||||
|
registry: options.authorization.registry,
|
||||||
|
realtime: options.moduleRealtime,
|
||||||
|
runtime,
|
||||||
|
services: options.moduleServices,
|
||||||
|
trustedOrigins: allowedOrigins,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,108 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||||
|
*
|
||||||
|
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||||
|
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||||
|
* package is never exposed as a static directory, and the source map beside the script is never
|
||||||
|
* read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value
|
||||||
|
* every response can reuse.
|
||||||
|
*/
|
||||||
|
export interface DashboardHostAssets {
|
||||||
|
readonly html: string;
|
||||||
|
readonly htmlEtag: string;
|
||||||
|
readonly script: string;
|
||||||
|
readonly scriptEtag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
|
* location, and machine path are intentionally not retained or surfaced. */
|
||||||
|
export class DashboardHostAssetError extends Error {
|
||||||
|
readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("The Dashboard host browser assets are unavailable.");
|
||||||
|
this.name = "DashboardHostAssetError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strongEtag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures one immutable, internally consistent Dashboard asset snapshot.
|
||||||
|
*
|
||||||
|
* Each strong validator is derived from the exact text in the same snapshot, so bytes from one build
|
||||||
|
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||||
|
* valid page that can never render anything.
|
||||||
|
*/
|
||||||
|
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
html,
|
||||||
|
htmlEtag: strongEtag(html),
|
||||||
|
script,
|
||||||
|
scriptEtag: strongEtag(script),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell
|
||||||
|
* reads the filled-in value at run time; empty means the page's own origin.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
|
||||||
|
|
||||||
|
function isCanonicalHttpOrigin(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (url.protocol === "https:" || url.protocol === "http:") && url.origin === value;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the API's own origin into the Dashboard page, once, before the page's `ETag` is computed.
|
||||||
|
*
|
||||||
|
* The origin is the identity base URL's, which is where `/api` lives in either topology: the page's
|
||||||
|
* own origin when the Dashboard and the API share one, the API hostname when they are split. The
|
||||||
|
* built page must carry exactly one empty placeholder; anything else is a shell build this API does
|
||||||
|
* not understand, and startup fails rather than serving a page that might send credentials somewhere
|
||||||
|
* unintended. The value is escaped for an attribute even though a canonical origin cannot contain
|
||||||
|
* any character that needs it.
|
||||||
|
*/
|
||||||
|
export function renderDashboardPage(html: string, apiOrigin: string): string {
|
||||||
|
const parts = html.split(DASHBOARD_API_ORIGIN_PLACEHOLDER);
|
||||||
|
if (parts.length !== 2 || !isCanonicalHttpOrigin(apiOrigin)) {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
const escaped = apiOrigin
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
|
||||||
|
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
|
||||||
|
* `/` with a 404 or a page that cannot find its API. */
|
||||||
|
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
|
||||||
|
try {
|
||||||
|
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
|
||||||
|
// rejected sibling read is left running after this function has already failed.
|
||||||
|
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||||
|
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||||
|
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
|
||||||
|
} catch {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
|
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||||
|
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||||
|
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||||
|
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||||
|
* `/api/v1/modules/<module-id>`.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||||
|
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||||
|
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
|
||||||
|
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
|
||||||
|
*/
|
||||||
|
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
|
||||||
|
* entry never keeps an older set.
|
||||||
|
*
|
||||||
|
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
|
||||||
|
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
|
||||||
|
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
|
||||||
|
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
|
||||||
|
*/
|
||||||
|
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
|
||||||
|
* package location, or asset content. */
|
||||||
|
export class DashboardHostConfigurationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("The Dashboard host configuration is inconsistent.");
|
||||||
|
this.name = "DashboardHostConfigurationError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies and validates injected assets before registering any route, as the display host does, so
|
||||||
|
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||||
|
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||||
|
try {
|
||||||
|
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||||
|
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedAsset(
|
||||||
|
ifNoneMatch: string | undefined,
|
||||||
|
body: string,
|
||||||
|
etag: string,
|
||||||
|
contentType: string,
|
||||||
|
extraHeaders: Readonly<Record<string, string>>,
|
||||||
|
): Response {
|
||||||
|
if (etagMatches(ifNoneMatch, etag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
...extraHeaders,
|
||||||
|
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
|
||||||
|
"Content-Type": contentType,
|
||||||
|
ETag: etag,
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||||
|
* default same-origin shape.
|
||||||
|
*
|
||||||
|
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||||
|
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||||
|
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||||
|
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||||
|
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||||
|
*/
|
||||||
|
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||||
|
const snapshot = snapshotAssets(assets);
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||||
|
fixedAsset(
|
||||||
|
c.req.header("if-none-match"),
|
||||||
|
snapshot.html,
|
||||||
|
snapshot.htmlEtag,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||||
|
fixedAsset(
|
||||||
|
c.req.header("if-none-match"),
|
||||||
|
snapshot.script,
|
||||||
|
snapshot.scriptEtag,
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import {
|
||||||
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_MODULES_UI_READ,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
DASHBOARD_SORT_ORDER_MAX,
|
||||||
|
DASHBOARD_SORT_ORDER_MIN,
|
||||||
|
type DashboardStore,
|
||||||
|
DashboardStoreError,
|
||||||
|
isValidDashboardIdentifier,
|
||||||
|
normalizeDashboardGroupLabel,
|
||||||
|
resolveDashboardNavigation,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
|
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js";
|
||||||
|
|
||||||
|
export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||||
|
const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`;
|
||||||
|
const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module as the Dashboard presents it: the module's display name, plus the generic mount
|
||||||
|
* descriptor unchanged.
|
||||||
|
*
|
||||||
|
* The descriptor is **embedded rather than flattened**, and that is the layering made visible.
|
||||||
|
* `module` is exactly `HostModuleUiDescriptor` — the host-neutral answer to "what is mountable and
|
||||||
|
* how" (ADR 0026) — and `name` is the Dashboard's presentation concern layered on top. A standalone
|
||||||
|
* host consumes the inner object and never learns that Ekklesia has folders; Ekklesia's shell
|
||||||
|
* consumes the outer one. Neither contract is warped to serve the other, and there is one
|
||||||
|
* construction of the descriptor rather than two that could drift.
|
||||||
|
*/
|
||||||
|
export interface DashboardNavigationModuleView {
|
||||||
|
readonly name: string;
|
||||||
|
readonly module: HostModuleUiDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationGroupView {
|
||||||
|
/** The stable key, unchanged by a rename. Addresses the group in the mutation API; never displayed. */
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly order: number;
|
||||||
|
readonly modules: readonly DashboardNavigationModuleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationResponse {
|
||||||
|
readonly groups: readonly DashboardNavigationGroupView[];
|
||||||
|
/** Modules an administrator placed outside every group, and modules never configured at all — a
|
||||||
|
* newly installed module most importantly. Never empty merely because nothing has been arranged. */
|
||||||
|
readonly ungrouped: readonly DashboardNavigationModuleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDashboardRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The same publication registry the artifact route and the generic inventory serve from: the one
|
||||||
|
* accepted, mountable module set. Navigation never rescans, never reads a manifest, and never
|
||||||
|
* discovers anything of its own. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
readonly store: DashboardStore;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. A
|
||||||
|
* validation failure the route already rejects can still arrive here from a concurrent change, so
|
||||||
|
* both paths are handled rather than assumed unreachable. */
|
||||||
|
function storeFailure(c: Context, error: unknown): Response {
|
||||||
|
if (error instanceof DashboardStoreError) {
|
||||||
|
if (error.code === "DASHBOARD_GROUP_NOT_FOUND") {
|
||||||
|
return sanitizedFailure(c, 404, error.code);
|
||||||
|
}
|
||||||
|
if (error.code === "DASHBOARD_READ_FAILED" || error.code === "DASHBOARD_WRITE_FAILED") {
|
||||||
|
return sanitizedFailure(c, 503, error.code);
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 503, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsedSortOrder(value: unknown): number | undefined {
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < DASHBOARD_SORT_ORDER_MIN ||
|
||||||
|
value > DASHBOARD_SORT_ORDER_MAX
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_BODY_KEYS = new Set(["label", "order"]);
|
||||||
|
const PLACEMENT_BODY_KEYS = new Set(["groupId", "order"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured, closed validation — never a free-form configuration blob. Any key beyond the declared
|
||||||
|
* ones makes the whole body invalid rather than being silently dropped, so a caller cannot smuggle a
|
||||||
|
* field a future version might interpret, and a typo fails loudly instead of quietly doing nothing.
|
||||||
|
*/
|
||||||
|
function parseGroupBody(raw: unknown): { label: string; order: number } | undefined {
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !GROUP_BODY_KEYS.has(key))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const order = parsedSortOrder(raw.order);
|
||||||
|
const label = normalizeDashboardGroupLabel(raw.label);
|
||||||
|
if (label === undefined || order === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { label, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlacementBody(raw: unknown): { groupId: string | null; order: number } | undefined {
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !PLACEMENT_BODY_KEYS.has(key))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const order = parsedSortOrder(raw.order);
|
||||||
|
const groupId = raw.groupId;
|
||||||
|
if (order === undefined || (groupId !== null && !isValidDashboardIdentifier(groupId))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { groupId, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(c: Context): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await c.req.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Ekklesia Dashboard's navigation: how this installation presents its mountable modules, and the
|
||||||
|
* narrow, structured API for changing that.
|
||||||
|
*
|
||||||
|
* ## Navigation is layered on the inventory, not merged into it
|
||||||
|
*
|
||||||
|
* `GET /api/v1/module-ui` (ADR 0026) answers *what may be mounted* and stays host-neutral — a
|
||||||
|
* standalone host uses it and never learns Ekklesia has folders. This route answers *how this
|
||||||
|
* Dashboard presents those modules*, and embeds each generic descriptor untouched inside a
|
||||||
|
* presentation wrapper. Keeping them separate is what stops Dashboard semantics leaking into the
|
||||||
|
* portable module-host contract.
|
||||||
|
*
|
||||||
|
* ## Grouping is presentation, and structurally cannot be anything else
|
||||||
|
*
|
||||||
|
* The resolved view is computed by `resolveDashboardNavigation`, a pure function of stored
|
||||||
|
* configuration and the current module set. It reads no manifest, no dependency, no capability, and
|
||||||
|
* no entrypoint, and it produces no relationship between two modules. Modules stay flat (ADR 0021,
|
||||||
|
* ADR 0027): two modules in one group are exactly as related as two in different groups, and moving
|
||||||
|
* one changes a single presentation row — never its artifact, its API root, its permissions, its
|
||||||
|
* dependencies, or when it starts.
|
||||||
|
*
|
||||||
|
* ## Reading versus organizing
|
||||||
|
*
|
||||||
|
* Reading requires `core.modules.ui.read`, the same permission the generic inventory requires,
|
||||||
|
* because this response *contains* that inventory and serves the same audience: any signed-in user
|
||||||
|
* needs it to see anything at all. Organizing requires `core.dashboard.navigation.manage`
|
||||||
|
* (restricted) — a platform permission, never a module's — and, like every other Core mutation, an
|
||||||
|
* exact trusted `Origin` and a bounded body before authorization work begins.
|
||||||
|
*
|
||||||
|
* Holding the manage permission grants no module authority. Every module route remains enforced
|
||||||
|
* against its own declared permission, and no arrangement of folders changes a single authorization
|
||||||
|
* decision.
|
||||||
|
*/
|
||||||
|
export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const middlewareOptions: AuthorizationMiddlewareOptions = {
|
||||||
|
evaluator: options.evaluator,
|
||||||
|
registry: options.registry,
|
||||||
|
runtime: options.runtime,
|
||||||
|
};
|
||||||
|
const requireRead = requirePermission(middlewareOptions, CORE_MODULES_UI_READ);
|
||||||
|
const requireManage = requirePermission(middlewareOptions, CORE_DASHBOARD_NAVIGATION_MANAGE);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_NAVIGATION_PATH, requireRead, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
// Per-principal: the embedded descriptors carry permission hints evaluated for this caller.
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
try {
|
||||||
|
const [configuration, descriptors] = await Promise.all([
|
||||||
|
options.store.readConfiguration(),
|
||||||
|
describeMountableModules(options.evaluator, principal, options.moduleUi),
|
||||||
|
]);
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration,
|
||||||
|
descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })),
|
||||||
|
);
|
||||||
|
const present = (entry: {
|
||||||
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
|
readonly name: string;
|
||||||
|
}): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name });
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
groups: view.groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
label: group.label,
|
||||||
|
modules: group.modules.map(present),
|
||||||
|
order: group.order,
|
||||||
|
})),
|
||||||
|
ungrouped: view.ungrouped.map(present),
|
||||||
|
} satisfies DashboardNavigationResponse,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DashboardStoreError) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
// An authorization-evaluation outage, exactly as the generic inventory reports it: a
|
||||||
|
// permission hint that could not be determined is never rendered as "not granted".
|
||||||
|
return sanitizedFailure(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
`${DASHBOARD_GROUPS_PATH}/:groupId`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const groupId = c.req.param("groupId");
|
||||||
|
const body = parseGroupBody(await readJsonBody(c));
|
||||||
|
if (!body || !isValidDashboardIdentifier(groupId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Idempotent by design: creating and renaming are the same operation against a
|
||||||
|
// client-chosen, stable id, so a rename can never orphan the placements pointing at it.
|
||||||
|
const group = await options.store.upsertGroup({
|
||||||
|
id: groupId,
|
||||||
|
label: body.label,
|
||||||
|
sortOrder: body.order,
|
||||||
|
});
|
||||||
|
return c.json({ id: group.id, label: group.label, order: group.sortOrder }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.delete(`${DASHBOARD_GROUPS_PATH}/:groupId`, requireOrigin, requireManage, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const groupId = c.req.param("groupId");
|
||||||
|
if (!isValidDashboardIdentifier(groupId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Modules in the group become ungrouped through the schema's `on delete set null`, so no
|
||||||
|
// module is lost and no placement is left pointing at a group that no longer exists.
|
||||||
|
await options.store.deleteGroup(groupId);
|
||||||
|
return c.body(null, 204);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
`${DASHBOARD_PLACEMENTS_PATH}/:moduleId`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const body = parsePlacementBody(await readJsonBody(c));
|
||||||
|
if (!body || !isValidDashboardIdentifier(moduleId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// A placement may be stored for a module that is not installed: that is how uninstalling
|
||||||
|
// and reinstalling preserves an arrangement. Nothing renders it until the module is back.
|
||||||
|
const placement = await options.store.setPlacement({
|
||||||
|
groupId: body.groupId,
|
||||||
|
moduleId,
|
||||||
|
sortOrder: body.order,
|
||||||
|
});
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
groupId: placement.groupId,
|
||||||
|
moduleId: placement.moduleId,
|
||||||
|
order: placement.sortOrder,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationDrizzleDatabase,
|
||||||
|
createAuthorizationDrizzleView,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
createDashboardDrizzleView,
|
||||||
|
type DashboardDrizzleDatabase,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||||
|
import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/core/devices";
|
||||||
|
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleConfigurationView,
|
||||||
|
createModuleDatabaseExecutor,
|
||||||
|
createModuleDatabaseView,
|
||||||
|
createModuleLifecycleView,
|
||||||
|
type ModuleConfigurationDrizzleDatabase,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleLifecycleDrizzleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
|
||||||
|
export interface ApiDatabaseHandle {
|
||||||
|
readonly authorization: AuthorizationDrizzleDatabase;
|
||||||
|
readonly devices: DeviceDrizzleDatabase;
|
||||||
|
readonly dashboard: DashboardDrizzleDatabase;
|
||||||
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
|
readonly module: ModuleDatabase;
|
||||||
|
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
|
||||||
|
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
|
||||||
|
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||||
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||||
|
/**
|
||||||
|
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
||||||
|
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
||||||
|
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
||||||
|
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
||||||
|
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
||||||
|
* the pool discards that connection instead of returning it, so a transaction left open by a
|
||||||
|
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
||||||
|
* caller may surface it.
|
||||||
|
*/
|
||||||
|
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
||||||
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||||
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pingStatement(statementTimeoutMs: number): string {
|
||||||
|
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
||||||
|
// parameters. Only a positive safe integer can ever reach the SQL text.
|
||||||
|
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
||||||
|
throw new RangeError("The ping statement timeout must be a positive integer.");
|
||||||
|
}
|
||||||
|
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||||
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by 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({
|
||||||
|
authorization: createAuthorizationDrizzleView(pool),
|
||||||
|
close,
|
||||||
|
devices: createDeviceDrizzleView(pool),
|
||||||
|
dashboard: createDashboardDrizzleView(pool),
|
||||||
|
identity: createIdentityDrizzleView(pool),
|
||||||
|
module: createModuleDatabaseView(pool),
|
||||||
|
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
|
||||||
|
moduleConfiguration: createModuleConfigurationView(pool),
|
||||||
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
|
ping: async (statementTimeoutMs: number) => {
|
||||||
|
await pool.query(pingStatement(statementTimeoutMs));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { createDevicePrincipal, type EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||||
|
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||||
|
import { DEVICE_LAST_SEEN_THROTTLE_MS } from "@ekklesia/core/devices";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import { DEVICE_SESSION_COOKIE } from "./device-routes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends principal resolution with managed devices, without touching how a *person* is resolved
|
||||||
|
* (ADR 0036).
|
||||||
|
*
|
||||||
|
* Composition rather than modification is deliberate. Core's identity runtime is the Better Auth
|
||||||
|
* boundary and knows only about human sessions; teaching it about devices would put two unrelated
|
||||||
|
* credential systems behind one implementation and make "which one answered?" an internal detail.
|
||||||
|
* Wrapping keeps Better Auth's behaviour byte-for-byte unchanged — including that an infrastructure
|
||||||
|
* failure still throws rather than degrading to anonymous — and adds exactly one fallback.
|
||||||
|
*
|
||||||
|
* **A human session always wins.** If Better Auth resolves a person, that is the answer and the
|
||||||
|
* device cookie is never even read. This matters because a browser can hold both: an administrator
|
||||||
|
* who opens a display page on their own laptop after pairing it stays themself, and no device
|
||||||
|
* cookie can dilute or replace a human identity.
|
||||||
|
*
|
||||||
|
* Because every gate in the platform — the display page, the descriptor, module HTTP routes,
|
||||||
|
* realtime — resolves principals through this one function, a device becomes a principal in exactly
|
||||||
|
* one place, and every authorization decision about it is still made by the ordinary evaluator.
|
||||||
|
*/
|
||||||
|
export interface DevicePrincipalOptions {
|
||||||
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
|
readonly devices: DeviceStore;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 6265 `cookie-string` parsing, matching how Core's own session-cookie detection reads a
|
||||||
|
* header: split on `;`, take the text before the first `=` as the name, compare whole names
|
||||||
|
* exactly. Never a substring test, so `x_ekklesia_device_session` is correctly not a match. */
|
||||||
|
function readCookie(headers: Headers, name: string): string | undefined {
|
||||||
|
const header = headers.get("cookie");
|
||||||
|
if (header === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const pair of header.split(";")) {
|
||||||
|
const separator = pair.indexOf("=");
|
||||||
|
if (separator === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pair.slice(0, separator).trim() === name) {
|
||||||
|
const value = pair.slice(separator + 1).trim();
|
||||||
|
return value.length === 0 ? undefined : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withDevicePrincipals(options: DevicePrincipalOptions): EkklesiaIdentityRuntime {
|
||||||
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
handleAuthRequest: (request: Request): Promise<Response> =>
|
||||||
|
options.runtime.handleAuthRequest(request),
|
||||||
|
|
||||||
|
async resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal> {
|
||||||
|
// Unchanged, including its failure behaviour: a genuine outage still throws rather than
|
||||||
|
// silently letting a device credential answer a question about a person.
|
||||||
|
const human = await options.runtime.resolvePrincipal(headers);
|
||||||
|
if (human.kind !== "anonymous") {
|
||||||
|
return human;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = readCookie(headers, DEVICE_SESSION_COOKIE);
|
||||||
|
if (credential === undefined) {
|
||||||
|
return human;
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = now();
|
||||||
|
const device = await options.devices.resolveCredential(credential, at);
|
||||||
|
if (!device) {
|
||||||
|
// A revoked, expired, or simply wrong credential is anonymous — never an error the caller
|
||||||
|
// could use to tell those three apart.
|
||||||
|
return human;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttled here as well as in SQL: the common case must not even issue a statement, or a
|
||||||
|
// display polling realtime would turn an operational nicety into continuous write traffic.
|
||||||
|
const lastSeen = device.lastSeenAt;
|
||||||
|
if (
|
||||||
|
lastSeen === undefined ||
|
||||||
|
at.getTime() - lastSeen.getTime() >= DEVICE_LAST_SEEN_THROTTLE_MS
|
||||||
|
) {
|
||||||
|
await options.devices.touchLastSeen(device.deviceId, at).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDevicePrincipal(device.deviceId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
|
||||||
|
import { CORE_DEVICES_MANAGE } from "@ekklesia/core/authorization";
|
||||||
|
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||||
|
import { DEVICE_CREDENTIAL_TTL_MS } from "@ekklesia/core/devices";
|
||||||
|
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import { Hono as HonoApp } from "hono";
|
||||||
|
import { getCookie, setCookie } from "hono/cookie";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-owned device identity transport: pairing a display, and managing what has been paired
|
||||||
|
* (ADR 0036).
|
||||||
|
*
|
||||||
|
* Both prefixes are siblings of `/api/v1/modules/...` and cannot collide with any module route.
|
||||||
|
* Nothing here is reachable by module code, and no module-facing contract mentions any of it — a
|
||||||
|
* module receives a principal and its permissions, and never learns that pairing exists.
|
||||||
|
*/
|
||||||
|
export const DEVICE_PAIRING_PREFIX = "/api/v1/device-pairing";
|
||||||
|
export const DEVICES_PREFIX = "/api/v1/devices";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device's two cookies, deliberately named nothing like Better Auth's session cookie.
|
||||||
|
*
|
||||||
|
* Distinct names are what keep the two identity systems from being confused for one another: the
|
||||||
|
* human resolver looks only for Better Auth's cookie and the device resolver looks only for these,
|
||||||
|
* so neither can ever be satisfied by the other's credential no matter what a caller sends.
|
||||||
|
*/
|
||||||
|
export const DEVICE_PAIRING_COOKIE = "ekklesia_device_pairing";
|
||||||
|
export const DEVICE_SESSION_COOKIE = "ekklesia_device_session";
|
||||||
|
|
||||||
|
/** How often an unpaired screen should ask again. Slow enough that a waiting display is not a load
|
||||||
|
* source, fast enough that someone standing at the screen sees it come alive. */
|
||||||
|
export const DEVICE_PAIRING_POLL_INTERVAL_MS = 3_000;
|
||||||
|
|
||||||
|
export interface DeviceRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
readonly devices: DeviceStore;
|
||||||
|
/** The same eligible/running publication registry the display routes use. It is the only source
|
||||||
|
* of the permission a pairing request may ask for. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
/** True when the platform is reached over https, which is what `Secure` requires to be usable at
|
||||||
|
* all. Derived from the validated identity base URL, never from a request. */
|
||||||
|
readonly secureCookies: boolean;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noStore(c: Context): void {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
|
||||||
|
noStore(c);
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonObject(c: Context): Promise<Record<string, unknown> | undefined> {
|
||||||
|
try {
|
||||||
|
const body: unknown = await c.req.json();
|
||||||
|
return typeof body === "object" && body !== null && !Array.isArray(body)
|
||||||
|
? (body as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(body: Record<string, unknown>, key: string, max: number): string | undefined {
|
||||||
|
const value = body[key];
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length === 0 || trimmed.length > max ? undefined : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one of the device cookies.
|
||||||
|
*
|
||||||
|
* `HttpOnly` so no script — least of all a module's display bundle, which runs in this very page —
|
||||||
|
* can read the value; `SameSite=Strict` because a display only ever talks to the host that serves
|
||||||
|
* it and nothing legitimate arrives cross-site; host-only (no `Domain`) so a sibling hostname
|
||||||
|
* cannot claim it; `Path=/` because the credential is presented to the descriptor, the module API,
|
||||||
|
* and realtime alike. `Secure` follows the deployment's own scheme — asserting it on a plaintext
|
||||||
|
* development origin would make the cookie silently unusable rather than more secure.
|
||||||
|
*/
|
||||||
|
function writeDeviceCookie(
|
||||||
|
c: Context,
|
||||||
|
options: DeviceRoutesOptions,
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
maxAgeSeconds: number,
|
||||||
|
): void {
|
||||||
|
setCookie(c, name, value, {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: maxAgeSeconds,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "Strict",
|
||||||
|
secure: options.secureCookies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||||
|
const routes = new HonoApp();
|
||||||
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
const requireManage = requirePermission(options, CORE_DEVICES_MANAGE);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unpaired display asks to be paired.
|
||||||
|
*
|
||||||
|
* The **only** thing the caller supplies is which module's display it is sitting on, and even
|
||||||
|
* that is not taken at its word: the module is looked up in the publication registry and the
|
||||||
|
* permission is read from that module's validated display declaration. A browser therefore cannot
|
||||||
|
* ask to be granted `core.devices.manage`, another module's permission, or anything else — the
|
||||||
|
* request records the permission the host derived, and approval grants exactly that.
|
||||||
|
*/
|
||||||
|
routes.post(
|
||||||
|
`${DEVICE_PAIRING_PREFIX}/requests`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
async (c: Context) => {
|
||||||
|
const body = await readJsonObject(c);
|
||||||
|
const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128);
|
||||||
|
if (moduleId === undefined) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const display = options.moduleUi
|
||||||
|
.listDisplays()
|
||||||
|
.find((candidate) => candidate.moduleId === moduleId);
|
||||||
|
if (!display) {
|
||||||
|
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
|
||||||
|
// pairing never becomes a way to enumerate what is installed.
|
||||||
|
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const at = now();
|
||||||
|
const created = await options.devices.createPairingRequest({
|
||||||
|
moduleId: display.moduleId,
|
||||||
|
now: at,
|
||||||
|
permissionId: display.displayPermission,
|
||||||
|
});
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
options,
|
||||||
|
DEVICE_PAIRING_COOKIE,
|
||||||
|
created.claimSecret,
|
||||||
|
Math.max(1, Math.ceil((created.expiresAt.getTime() - at.getTime()) / 1000)),
|
||||||
|
);
|
||||||
|
noStore(c);
|
||||||
|
// The code is returned because it has to be shown on the screen. The claim secret is not:
|
||||||
|
// it goes only into the HttpOnly cookie above, so reading the code off a television tells
|
||||||
|
// an onlooker nothing they could use.
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
code: created.code,
|
||||||
|
expiresAt: created.expiresAt.toISOString(),
|
||||||
|
pollIntervalMs: DEVICE_PAIRING_POLL_INTERVAL_MS,
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display asks whether it has been approved yet, and collects its credential when it has.
|
||||||
|
*
|
||||||
|
* Authority here is the claim cookie and nothing else. The pairing code is not accepted — it is
|
||||||
|
* public, printed across a screen, and a flow that issued a credential to whoever presented it
|
||||||
|
* would hand the device's identity to anyone who walked past. Consumption is single-use inside
|
||||||
|
* the store, so a replayed or concurrent claim never yields a second credential.
|
||||||
|
*/
|
||||||
|
routes.post(
|
||||||
|
`${DEVICE_PAIRING_PREFIX}/claims`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
async (c: Context) => {
|
||||||
|
const claimSecret = getCookie(c, DEVICE_PAIRING_COOKIE);
|
||||||
|
if (claimSecret === undefined || claimSecret.length === 0) {
|
||||||
|
return sanitizedFailure(c, 400, "DEVICE_PAIRING_UNKNOWN");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const at = now();
|
||||||
|
const result = await options.devices.claimPairingRequest({ claimSecret, now: at });
|
||||||
|
noStore(c);
|
||||||
|
if (result.paired) {
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
options,
|
||||||
|
DEVICE_SESSION_COOKIE,
|
||||||
|
result.paired.credential,
|
||||||
|
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||||
|
);
|
||||||
|
// The pairing cookie has done its job; leaving it would keep a spent secret on the device.
|
||||||
|
writeDeviceCookie(c, options, DEVICE_PAIRING_COOKIE, "", 0);
|
||||||
|
// Deliberately no credential, device id, or permission in the body: the browser needs to
|
||||||
|
// know only that it may now load the display, and everything it needs travels in the
|
||||||
|
// Set-Cookie header where script cannot reach it.
|
||||||
|
return c.json({ status: "paired" as const }, 200);
|
||||||
|
}
|
||||||
|
return c.json({ status: result.status }, 200);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An administrator types the code from the screen and sees what it is asking for, before deciding.
|
||||||
|
*
|
||||||
|
* The code travels in a request body rather than a path or query so it does not land in access
|
||||||
|
* logs, browser history, or a `Referer`. Reaching this at all requires `core.devices.manage`, so
|
||||||
|
* it is not an enumeration oracle for anyone who is not already trusted to approve pairings.
|
||||||
|
*/
|
||||||
|
routes.post(
|
||||||
|
`${DEVICE_PAIRING_PREFIX}/lookups`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c: Context) => {
|
||||||
|
const body = await readJsonObject(c);
|
||||||
|
const code = body === undefined ? undefined : readString(body, "code", 16);
|
||||||
|
if (code === undefined || !/^[0-9]{6}$/u.test(code)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const found = await options.devices.findPendingByCode(code, now());
|
||||||
|
if (!found) {
|
||||||
|
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
|
||||||
|
}
|
||||||
|
noStore(c);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
expiresAt: found.expiresAt.toISOString(),
|
||||||
|
moduleId: found.moduleId,
|
||||||
|
permissionId: found.permissionId,
|
||||||
|
requestedAt: found.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Approval. The administrator names the device; the unpaired screen never names itself. */
|
||||||
|
routes.post(
|
||||||
|
`${DEVICE_PAIRING_PREFIX}/approvals`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c: Context) => {
|
||||||
|
const body = await readJsonObject(c);
|
||||||
|
const code = body === undefined ? undefined : readString(body, "code", 16);
|
||||||
|
const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128);
|
||||||
|
if (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === undefined) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
if (principal.kind !== "authenticated") {
|
||||||
|
// Unreachable through `requireManage`, which only ever admits a person. Stated rather than
|
||||||
|
// assumed, so a device could never be recorded as having approved its own pairing.
|
||||||
|
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const approved = await options.devices.approvePairingRequest({
|
||||||
|
approvedBy: principal.userId,
|
||||||
|
code,
|
||||||
|
deviceName,
|
||||||
|
now: now(),
|
||||||
|
});
|
||||||
|
if (!approved) {
|
||||||
|
// Expired, already approved, already consumed, or never existed. One answer for all of
|
||||||
|
// them: which one it was is not information this endpoint owes anybody.
|
||||||
|
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
|
||||||
|
}
|
||||||
|
noStore(c);
|
||||||
|
return c.json({ moduleId: approved.moduleId, permissionId: approved.permissionId }, 200);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
||||||
|
try {
|
||||||
|
const devices = await options.devices.listDevices();
|
||||||
|
noStore(c);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
devices: devices.map((device) => ({
|
||||||
|
createdAt: device.createdAt.toISOString(),
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
permissions: device.permissions,
|
||||||
|
revoked: device.revokedAt !== undefined,
|
||||||
|
...(device.lastSeenAt === undefined
|
||||||
|
? {}
|
||||||
|
: { lastSeenAt: device.lastSeenAt.toISOString() }),
|
||||||
|
...(device.revokedAt === undefined
|
||||||
|
? {}
|
||||||
|
: { revokedAt: device.revokedAt.toISOString() }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revocation, which is a server-side fact rather than a request that the remote browser discard
|
||||||
|
* anything. The device's own copy of its credential keeps existing and stops working.
|
||||||
|
*/
|
||||||
|
routes.post(
|
||||||
|
`${DEVICES_PREFIX}/:deviceId/revoke`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c: Context) => {
|
||||||
|
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
if (principal.kind !== "authenticated") {
|
||||||
|
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.devices.revokeDevice({
|
||||||
|
deviceId: parsed.data,
|
||||||
|
now: now(),
|
||||||
|
revokedBy: principal.userId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
noStore(c);
|
||||||
|
return c.body(null, 204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||||
|
*
|
||||||
|
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||||
|
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||||
|
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
|
||||||
|
* genuinely immutable value that every response can reuse safely.
|
||||||
|
*/
|
||||||
|
export interface DisplayHostAssets {
|
||||||
|
readonly html: string;
|
||||||
|
readonly script: string;
|
||||||
|
readonly scriptEtag: string;
|
||||||
|
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
|
||||||
|
* at a path whose directory is its scope so it controls display pages and nothing else. */
|
||||||
|
readonly serviceWorker: string;
|
||||||
|
readonly serviceWorkerEtag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
|
* location, and machine path are intentionally not retained or surfaced. */
|
||||||
|
export class DisplayHostAssetError extends Error {
|
||||||
|
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("The display host browser assets are unavailable.");
|
||||||
|
this.name = "DisplayHostAssetError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures one immutable, internally consistent display-host asset snapshot.
|
||||||
|
*
|
||||||
|
* The strong validator is always derived from the exact script text in the same snapshot; callers
|
||||||
|
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||||
|
* failed host build rather than a valid page that can never mount anything.
|
||||||
|
*/
|
||||||
|
export function createDisplayHostAssets(
|
||||||
|
html: string,
|
||||||
|
script: string,
|
||||||
|
serviceWorker: string,
|
||||||
|
): DisplayHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||||
|
throw new DisplayHostAssetError();
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
html,
|
||||||
|
script,
|
||||||
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
|
||||||
|
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||||
|
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||||
|
try {
|
||||||
|
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
|
||||||
|
// rejected sibling promise is left running after this function has already failed.
|
||||||
|
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||||
|
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||||
|
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
||||||
|
return createDisplayHostAssets(html, script, serviceWorker);
|
||||||
|
} catch {
|
||||||
|
throw new DisplayHostAssetError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 @@
|
|||||||
|
/**
|
||||||
|
* Canonical browser routes owned by the platform's display host.
|
||||||
|
*
|
||||||
|
* Kept separate from the route registration module so both the display host and module-UI
|
||||||
|
* inventory can publish the same path without introducing a dependency cycle.
|
||||||
|
*/
|
||||||
|
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||||
|
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||||
|
|
||||||
|
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||||
|
import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules";
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
|
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
export {
|
||||||
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||||
|
MODULE_DISPLAY_PAGE_PREFIX,
|
||||||
|
moduleDisplayDescriptorUrl,
|
||||||
|
moduleDisplayPageUrl,
|
||||||
|
} from "./module-display-path.js";
|
||||||
|
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
|
||||||
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
|
import {
|
||||||
|
describeModuleContributions,
|
||||||
|
grantedModulePermissionIds,
|
||||||
|
type HostModuleContributionDescriptor,
|
||||||
|
} from "./module-ui-inventory.js";
|
||||||
|
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||||
|
|
||||||
|
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||||
|
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
|
||||||
|
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||||
|
/**
|
||||||
|
* Where the display's offline service worker is served (ADR 0043).
|
||||||
|
*
|
||||||
|
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
|
||||||
|
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
|
||||||
|
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
|
||||||
|
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||||
|
*
|
||||||
|
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
|
||||||
|
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
|
||||||
|
|
||||||
|
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
|
||||||
|
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
|
||||||
|
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
|
||||||
|
* boundary. */
|
||||||
|
export interface HostModuleDisplayDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly display: { readonly url: string };
|
||||||
|
/** The public page a screen opens to mount this display. This is distinct from `display.url`,
|
||||||
|
* which is the compiled artifact imported by the platform host. */
|
||||||
|
readonly page: { readonly url: string };
|
||||||
|
readonly api: { readonly baseUrl: string };
|
||||||
|
readonly realtime: { readonly baseUrl: string };
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
|
||||||
|
* mechanism a signage-style display uses to show another module's content without importing it. */
|
||||||
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who the host resolved as the viewer of this display.
|
||||||
|
*
|
||||||
|
* The descriptor reports it because the display host must not ask `/api/v1/principal` — that
|
||||||
|
* endpoint answers a question about *people*, and a paired device is deliberately anonymous to it
|
||||||
|
* (ADR 0036). Reading the viewer from the response that already performed the authorization means
|
||||||
|
* the display never has to infer "no human session, so presumably a device", and the two identity
|
||||||
|
* classes never have to be told apart by elimination.
|
||||||
|
*/
|
||||||
|
export type HostDisplayViewer =
|
||||||
|
| { readonly kind: "authenticated"; readonly userId: string }
|
||||||
|
| { readonly kind: "device"; readonly deviceId: string };
|
||||||
|
|
||||||
|
export interface HostModuleDisplayResource {
|
||||||
|
readonly module: HostModuleDisplayDescriptor;
|
||||||
|
readonly viewer: HostDisplayViewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The same immutable, eligible/running publication registry used for artifact bytes. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||||
|
* surface-owned files before binding and supplies their frozen contents here. */
|
||||||
|
readonly assets?: DisplayHostAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||||
|
* permission id, path, or package location. */
|
||||||
|
export class ModuleDisplayConfigurationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("The module display host configuration is inconsistent.");
|
||||||
|
this.name = "ModuleDisplayConfigurationError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayFailure(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||||
|
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||||
|
* bytes with a stale validator. */
|
||||||
|
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||||
|
try {
|
||||||
|
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||||
|
if (
|
||||||
|
snapshot.scriptEtag !== input.scriptEtag ||
|
||||||
|
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||||
|
) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeViewer(principal: EkklesiaPrincipal): HostDisplayViewer | undefined {
|
||||||
|
if (principal.kind === "authenticated") {
|
||||||
|
return { kind: "authenticated", userId: principal.userId };
|
||||||
|
}
|
||||||
|
if (principal.kind === "device") {
|
||||||
|
return { deviceId: principal.deviceId, kind: "device" };
|
||||||
|
}
|
||||||
|
// Unreachable behind the permission gate: the display permission is `restricted`, which no
|
||||||
|
// anonymous principal satisfies. Stated rather than assumed.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeDisplay(
|
||||||
|
options: ModuleDisplayRoutesOptions,
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
display: PublishableModuleDisplay,
|
||||||
|
viewer: HostDisplayViewer,
|
||||||
|
): Promise<HostModuleDisplayResource> {
|
||||||
|
return {
|
||||||
|
viewer,
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||||
|
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||||
|
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||||
|
id: display.moduleId,
|
||||||
|
page: { url: moduleDisplayPageUrl(display.moduleId) },
|
||||||
|
permissions: await grantedModulePermissionIds(
|
||||||
|
options.evaluator,
|
||||||
|
principal,
|
||||||
|
display.permissions,
|
||||||
|
),
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) },
|
||||||
|
version: display.version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountDisplay(
|
||||||
|
app: Hono,
|
||||||
|
options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets },
|
||||||
|
display: PublishableModuleDisplay,
|
||||||
|
): void {
|
||||||
|
// The permission comes from an already-validated manifest/publication record. A request selects
|
||||||
|
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
|
||||||
|
const requireDisplayPermission = requirePermission(options, display.displayPermission);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page itself is **not** permission-gated, and the descriptor below still is.
|
||||||
|
*
|
||||||
|
* An unattended screen has no identity until someone pairs it, so a gate here would mean a new
|
||||||
|
* television could only ever be told 403 — there would be nowhere for it to display a pairing
|
||||||
|
* code, and the only way to adopt one would be to sign a human into it, which is exactly the
|
||||||
|
* model this pass exists to remove (ADR 0036).
|
||||||
|
*
|
||||||
|
* Serving it costs nothing, because it carries nothing: these are the same inert, module-agnostic
|
||||||
|
* bytes for every display, with no module id, no data, and one absolute script URL that was
|
||||||
|
* already public. Authorization lives entirely in the descriptor — the page can render, and then
|
||||||
|
* learns it may show only a pairing code. What this does reveal is that a module with a display
|
||||||
|
* exists at that path, which any pairing flow must reveal to the device it is pairing.
|
||||||
|
*/
|
||||||
|
app.get(moduleDisplayPageUrl(display.moduleId), () => {
|
||||||
|
return new Response(options.assets.html, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
try {
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
const viewer = describeViewer(principal);
|
||||||
|
if (!viewer) {
|
||||||
|
return displayFailure(c);
|
||||||
|
}
|
||||||
|
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||||
|
} catch {
|
||||||
|
return displayFailure(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts one stable page and one descriptor route per eligible, running display declaration.
|
||||||
|
*
|
||||||
|
* There is deliberately no display inventory in this pass. A management UI may need one later,
|
||||||
|
* but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only,
|
||||||
|
* disabled, and dependency-blocked modules have no registered route and collapse into the
|
||||||
|
* application's ordinary sanitized 404.
|
||||||
|
*
|
||||||
|
* The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute
|
||||||
|
* script URL cannot be rebased by a deep `/display/<id>` path. The browser reads the validated id
|
||||||
|
* from its outer URL, requests the permission-gated descriptor, and only then imports the exact
|
||||||
|
* versioned display artifact that the server supplied.
|
||||||
|
*/
|
||||||
|
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
|
||||||
|
const displays = options.moduleUi.listDisplays();
|
||||||
|
if (displays.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!options.assets) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
const assets = snapshotAssets(options.assets);
|
||||||
|
for (const display of displays) {
|
||||||
|
if (!options.registry.has(display.displayPermission)) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One fixed host bundle, never a directory and never a request-selected package file. Like module
|
||||||
|
// artifact bytes, fetching this platform code grants no authority; the page and descriptor are
|
||||||
|
// where identity and the module-owned display permission are enforced.
|
||||||
|
app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => {
|
||||||
|
if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(assets.script, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/javascript; charset=utf-8",
|
||||||
|
ETag: assets.scriptEtag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
|
||||||
|
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
|
||||||
|
* how a screen avoids being trapped on an obsolete worker.
|
||||||
|
*
|
||||||
|
* Like the host script and module artifact bytes, fetching this code grants no authority. It
|
||||||
|
* caches only public code and never a permission decision, so serving it anonymously withholds
|
||||||
|
* nothing the platform was protecting.
|
||||||
|
*/
|
||||||
|
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
|
||||||
|
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(assets.serviceWorker, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/javascript; charset=utf-8",
|
||||||
|
ETag: assets.serviceWorkerEtag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const display of displays) {
|
||||||
|
mountDisplay(app, { ...options, assets }, display);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,296 @@
|
|||||||
|
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||||
|
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||||
|
import type {
|
||||||
|
ModuleUiPublicationRegistry,
|
||||||
|
PublishableModuleContribution,
|
||||||
|
PublishableModuleUi,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
|
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
|
import {
|
||||||
|
MODULE_UI_ASSET_PREFIX,
|
||||||
|
moduleContributionArtifactUrl,
|
||||||
|
moduleUiArtifactUrl,
|
||||||
|
} from "./module-ui-routes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
|
||||||
|
* mount, and `/api/v1/module-ui/<module-id>/<version>` (ADR 0025) serves one artifact's bytes. One
|
||||||
|
* prefix, one resource, two representations.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_INVENTORY_PATH = MODULE_UI_ASSET_PREFIX;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly what a browser host needs to mount one module's UI, and deliberately nothing else.
|
||||||
|
*
|
||||||
|
* No filesystem path, package root, package name, server/schema/migrations entrypoint, manifest
|
||||||
|
* version, capability, dependency, or storage bucket appears here. Those are server-side facts a
|
||||||
|
* browser has no use for, and several of them are precisely what ADR 0025's publication route
|
||||||
|
* exists to keep off the wire.
|
||||||
|
*
|
||||||
|
* There is also no `name`, `icon`, `description`, `category`, `folder`, or `order`. Those are
|
||||||
|
* *navigation* concerns, and ADR 0021 established that Dashboard grouping is presentation
|
||||||
|
* configuration an administrator owns rather than something a module declares. A field added here
|
||||||
|
* now would quietly become module-owned navigation metadata before that design exists; the
|
||||||
|
* temporary selector renders the module id instead.
|
||||||
|
*/
|
||||||
|
export interface HostModuleUiDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
/** Where the browser fetches this module's compiled UI artifact. Produced by the server's own
|
||||||
|
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
||||||
|
* routing can change without touching a single line of host or module code. */
|
||||||
|
readonly ui: { readonly url: string };
|
||||||
|
/** Canonical page for this module's separately declared display artifact, or `null` when the
|
||||||
|
* module publishes no display. Server-produced so neither shell nor module spells platform
|
||||||
|
* routing or infers deployment topology. */
|
||||||
|
readonly display: { readonly url: string } | null;
|
||||||
|
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
|
||||||
|
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
|
||||||
|
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
|
||||||
|
* module nor the browser host should be the one spelling it. */
|
||||||
|
readonly api: { readonly baseUrl: string };
|
||||||
|
/** Server-produced root for this module's host-neutral browser realtime facade. The browser host,
|
||||||
|
* not module UI, resolves and appends a validated local channel. */
|
||||||
|
readonly realtime: { readonly baseUrl: string };
|
||||||
|
/**
|
||||||
|
* The subset of **this module's own declared permissions** that the current principal holds,
|
||||||
|
* evaluated server-side by Core's one authorization evaluator.
|
||||||
|
*
|
||||||
|
* Scoped to the module deliberately: a host has no business handing one module the platform's
|
||||||
|
* whole authorization state, and the module's manifest already declares exactly which permissions
|
||||||
|
* its UI can meaningfully ask about. Granted ids only — a permission the principal lacks is simply
|
||||||
|
* absent, so this never enumerates a module's full permission surface to someone who holds none of
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* **These are presentation hints, not authority** (ADR 0022). They exist so a UI can hide a
|
||||||
|
* control instead of offering one that would fail. Every module route remains enforced by
|
||||||
|
* `requirePermission` against the composed platform registry, and that enforcement neither reads
|
||||||
|
* nor trusts anything in this array.
|
||||||
|
*/
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
|
||||||
|
* the overwhelming majority of modules, which provide no extension point. */
|
||||||
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
|
||||||
|
*
|
||||||
|
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
|
||||||
|
* consuming module names a contribution and the host resolves it, so no module ever builds
|
||||||
|
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
|
||||||
|
*
|
||||||
|
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
|
||||||
|
* contributed renderer draws and what shape its input takes are the consuming module's contract,
|
||||||
|
* carried in the browser between two modules without passing through the platform.
|
||||||
|
*/
|
||||||
|
export interface HostModuleContributionDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
/** The capability it was contributed to — one this module declares. */
|
||||||
|
readonly capability: string;
|
||||||
|
readonly url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostModuleUiInventory {
|
||||||
|
readonly modules: readonly HostModuleUiDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
|
||||||
|
* inventory and the display descriptor so the two can never disagree about what a consuming module
|
||||||
|
* may mount.
|
||||||
|
*/
|
||||||
|
export function describeModuleContributions(
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
moduleId: string,
|
||||||
|
): readonly HostModuleContributionDescriptor[] {
|
||||||
|
return registry
|
||||||
|
.contributionsForModule(moduleId)
|
||||||
|
.map((contribution: PublishableModuleContribution) => ({
|
||||||
|
capability: contribution.capability,
|
||||||
|
id: contribution.contributionId,
|
||||||
|
url: moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
|
||||||
|
contribution.contributionId,
|
||||||
|
),
|
||||||
|
version: contribution.version,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||||
|
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||||
|
* no discovery, reads no manifest, and touches no filesystem; a module absent from this registry
|
||||||
|
* is absent from the inventory, by construction rather than by filtering. Named `moduleUi` rather
|
||||||
|
* than `registry` because `AuthorizationMiddlewareOptions.registry` is already the *permission*
|
||||||
|
* registry, and one route needs both. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates one module's declared permissions against the caller, returning only the granted ids.
|
||||||
|
*
|
||||||
|
* Uses Core's one evaluator, so baselines, the platform-owner bypass, grant expiry, and
|
||||||
|
* deny-by-default all behave exactly as they do on a real request — there is no second,
|
||||||
|
* browser-only interpretation of what a permission means. An infrastructure failure propagates as
|
||||||
|
* `AuthorizationEvaluationError` rather than being folded into "not granted", because a hint that
|
||||||
|
* silently read "denied" during a database outage would blank the whole UI and look like a
|
||||||
|
* permissions problem.
|
||||||
|
*/
|
||||||
|
/** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same
|
||||||
|
* presentation-only subset of a module's own declarations and use Core's one evaluator. */
|
||||||
|
export async function grantedModulePermissionIds(
|
||||||
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
declarations: readonly PermissionDeclaration[],
|
||||||
|
): Promise<readonly string[]> {
|
||||||
|
const decisions = await Promise.all(
|
||||||
|
declarations.map(async (declaration) => ({
|
||||||
|
allowed: (
|
||||||
|
await evaluator.authorize({
|
||||||
|
baseline: declaration.baseline,
|
||||||
|
permission: declaration.id,
|
||||||
|
principal,
|
||||||
|
})
|
||||||
|
).allowed,
|
||||||
|
id: String(declaration.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return decisions.filter((decision) => decision.allowed).map((decision) => decision.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeModule(
|
||||||
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
published: PublishableModuleUi,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
): Promise<HostModuleUiDescriptor> {
|
||||||
|
const display = registry.resolveDisplay(published.moduleId, published.version);
|
||||||
|
return {
|
||||||
|
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||||
|
contributions: describeModuleContributions(registry, published.moduleId),
|
||||||
|
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
|
||||||
|
id: published.moduleId,
|
||||||
|
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||||
|
ui: { url: moduleUiArtifactUrl(published) },
|
||||||
|
version: published.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One mountable module, described once: the host-neutral mount descriptor plus the module's own
|
||||||
|
* display name, kept beside it rather than inside it. */
|
||||||
|
export interface MountableModuleDescription {
|
||||||
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
|
/** `manifest.name`. Presentation *identity* the module owns — never its placement, which the
|
||||||
|
* Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the
|
||||||
|
* host-neutral contract a standalone host consumes stays free of presentation concerns. */
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one composition of the mountable-module set, shared by the generic inventory route and the
|
||||||
|
* Dashboard navigation route (`./dashboard-routes.js`).
|
||||||
|
*
|
||||||
|
* Both derive from the same publication registry and the same evaluator, so the two endpoints can
|
||||||
|
* never disagree about what exists, what its URLs are, or what the caller may do inside it. Sharing
|
||||||
|
* this function rather than duplicating the mapping is what makes that a structural property instead
|
||||||
|
* of a convention.
|
||||||
|
*/
|
||||||
|
export async function describeMountableModules(
|
||||||
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
): Promise<readonly MountableModuleDescription[]> {
|
||||||
|
return Promise.all(
|
||||||
|
registry.list().map(async (published) => ({
|
||||||
|
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||||
|
name: published.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that
|
||||||
|
* could not be determined is never reported as "not granted". Carries no module id, permission id,
|
||||||
|
* or caught error. */
|
||||||
|
function sanitizedInventoryFailure(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser host's one bootstrap query: which module UIs may I mount, where do I fetch each one,
|
||||||
|
* where does each one's API live, and what may this viewer do inside it.
|
||||||
|
*
|
||||||
|
* ## It is not a module management API
|
||||||
|
*
|
||||||
|
* It lists mountable UIs and nothing else. There is no create, update, enable, disable, install, or
|
||||||
|
* configure; no module that declares no UI; and no field beyond what constructing a
|
||||||
|
* `ModuleUiHostContext` requires. Ekklesia has no enable/disable state to report —
|
||||||
|
* `ModuleLifecycleStage` is vocabulary with no runtime state machine behind it — and inventing one
|
||||||
|
* here would be inventing a lifecycle rather than reporting one.
|
||||||
|
*
|
||||||
|
* ## The module set comes from the publication registry, never a rescan
|
||||||
|
*
|
||||||
|
* `moduleUi.list()` is the exact accepted set ADR 0025 publishes artifacts from: modules that
|
||||||
|
* survived discovery and declare `entrypoints.ui`, captured after the module runtime started. This
|
||||||
|
* route never scans a directory, reads a manifest, or reconstructs a module from `package.json`, so
|
||||||
|
* the inventory and the artifact route can never disagree about what exists. Order is the
|
||||||
|
* registry's own discovery order, which is deterministic (topologically sorted, then by id).
|
||||||
|
*
|
||||||
|
* ## Authenticated, deliberately, unlike the artifact route
|
||||||
|
*
|
||||||
|
* ADR 0025 made the artifact bytes anonymous — fetching compiled UI code confers no authority — but
|
||||||
|
* was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong
|
||||||
|
* version collapse into one indistinguishable `404`, so a caller must already know both halves of an
|
||||||
|
* identity to learn anything. **This endpoint is that enumeration.** Serving it anonymously would
|
||||||
|
* hand over exactly what that route withholds, so it requires `core.modules.ui.read` (baseline
|
||||||
|
* `authenticated`): any signed-in user of the installation, and nobody else.
|
||||||
|
*
|
||||||
|
* Enforcement goes through the same `requirePermission` middleware every other protected route uses
|
||||||
|
* — no second browser-auth mechanism — which also resolves the principal exactly once and keeps a
|
||||||
|
* genuine denial (`403`) distinct from an infrastructure failure (`503`).
|
||||||
|
*
|
||||||
|
* The response is per-principal (its `permissions` depend on who is asking), so it is `no-store` and
|
||||||
|
* varies on `Origin` and `Cookie`, exactly like `/api/v1/principal`.
|
||||||
|
*/
|
||||||
|
export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const requireInventoryPermission = requirePermission(
|
||||||
|
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||||
|
CORE_MODULES_UI_READ,
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(MODULE_UI_INVENTORY_PATH, requireInventoryPermission, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
// Resolved and already authorized by the middleware above; never resolved a second time here.
|
||||||
|
// The `authenticated` baseline guarantees this is an authenticated principal.
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
try {
|
||||||
|
const described = await describeMountableModules(
|
||||||
|
options.evaluator,
|
||||||
|
principal,
|
||||||
|
options.moduleUi,
|
||||||
|
);
|
||||||
|
// Only the host-neutral descriptor reaches this response: the display name stays out of the
|
||||||
|
// portable contract and is carried by the Dashboard navigation view instead.
|
||||||
|
const modules = described.map((entry) => entry.descriptor);
|
||||||
|
return c.json({ modules } satisfies HostModuleUiInventory, 200);
|
||||||
|
} catch {
|
||||||
|
return sanitizedInventoryFailure(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform-owned URL space for published module UI artifacts.
|
||||||
|
*
|
||||||
|
* Deliberately **not** nested under `/api/v1/modules/<module-id>`, and that is a security decision
|
||||||
|
* rather than a stylistic one. A module may declare any route path matching Core's own grammar
|
||||||
|
* (`moduleRoutePathSchema`, `@ekklesia/contracts`), and `ui` is a perfectly legal lowercase static
|
||||||
|
* segment — so a module registering `GET /ui/:version` would be mounted at exactly
|
||||||
|
* `/api/v1/modules/<module-id>/ui/<version>`. Publishing there would put the platform and installed
|
||||||
|
* modules in the same URL namespace, with Hono's registration order silently deciding which one
|
||||||
|
* answers. A sibling prefix can never collide: every module route begins `/api/v1/modules/`, and
|
||||||
|
* nothing the platform serves here does.
|
||||||
|
*
|
||||||
|
* It stays under `/api/v1/*` so it inherits the application's one CORS policy (exact reflected
|
||||||
|
* trusted origins, never a wildcard) — which a cross-origin browser `import()` of this URL requires
|
||||||
|
* — instead of introducing a second, separately-configured public URL space.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_ASSET_PREFIX = "/api/v1/module-ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revalidate-always, with a strong `ETag` doing the actual work.
|
||||||
|
*
|
||||||
|
* The URL carries module id and exact installed version, which is *identity*, not *immutability*.
|
||||||
|
* Ekklesia has no installer, no content addressing, and no integrity verification, so nothing stops
|
||||||
|
* an operator from rebuilding version `1.2.3` with different bytes and restarting — the ordinary
|
||||||
|
* inner-loop of developing a module. `immutable` (or any non-zero `max-age`) would therefore be a
|
||||||
|
* promise the platform cannot keep, and a browser that cached a superseded artifact under a reused
|
||||||
|
* version would have no way to recover.
|
||||||
|
*
|
||||||
|
* `no-cache` is not `no-store`: the response is still cached, it just has to be revalidated. With
|
||||||
|
* the `ETag` below, an unchanged artifact costs one conditional request and a `304`, and a changed
|
||||||
|
* one is picked up immediately. A version bump changes the URL and never collides at all. When a
|
||||||
|
* real installer can guarantee that an installed version's bytes never change, this is the single
|
||||||
|
* constant that would move to immutable caching.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_CACHE_CONTROL = "no-cache";
|
||||||
|
|
||||||
|
/** Modern ESM is served as `text/javascript`, the one JavaScript type the WHATWG MIME sniffing
|
||||||
|
* standard designates as canonical; `application/javascript` is a legacy alias. A browser refuses
|
||||||
|
* to evaluate an ES module served as anything else, so this is a correctness requirement, not a
|
||||||
|
* courtesy. */
|
||||||
|
export const MODULE_UI_CONTENT_TYPE = "text/javascript; charset=utf-8";
|
||||||
|
|
||||||
|
export interface ModuleUiIdentity {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one canonical way to turn an installed module's identity into the browser URL its UI artifact
|
||||||
|
* is published at. Every producer of that URL — this package's own route registration, a future
|
||||||
|
* module-metadata endpoint, and a future Dashboard host — goes through here, so the URL shape is
|
||||||
|
* defined once instead of being re-spelled as a template string in each of them.
|
||||||
|
*
|
||||||
|
* The result is a **root-relative browser URL, never a filesystem path** — the distinction ADR 0024
|
||||||
|
* built the loader around. A host on the API's own origin passes it to `loadModuleUi` unchanged; a
|
||||||
|
* host on a different origin resolves it against the same API base URL it already needs for
|
||||||
|
* `ModuleUiApiClient` (`new URL(moduleUiArtifactUrl(identity), apiBaseUrl)`). It is deliberately not
|
||||||
|
* absolute: the API's public origin is deployment configuration, and baking one in here would break
|
||||||
|
* both split-origin deployments and reverse proxies.
|
||||||
|
*
|
||||||
|
* This function accepts **identity only**. There is no parameter for a filename, a relative path,
|
||||||
|
* or any other artifact selector, so no caller — however careless — can use it to reach a second
|
||||||
|
* file in a module package. Both components are percent-encoded, so even a value that never came
|
||||||
|
* from a validated manifest can only ever produce one extra path segment's worth of text, never a
|
||||||
|
* new segment, a traversal, a query, or a fragment. A value that needed encoding will simply fail
|
||||||
|
* to resolve against the publication registry.
|
||||||
|
*/
|
||||||
|
export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||||
|
return `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(identity.moduleId)}/${encodeURIComponent(
|
||||||
|
identity.version,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical browser URL for a module's independently declared display artifact.
|
||||||
|
*
|
||||||
|
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
|
||||||
|
* separate helper and an exact route means neither request input nor a generic role parameter can
|
||||||
|
* select a neighbouring artifact. A future standalone host may expose a different outer display
|
||||||
|
* page while consuming this same published module artifact internally.
|
||||||
|
*/
|
||||||
|
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||||
|
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
|
||||||
|
*
|
||||||
|
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
|
||||||
|
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
|
||||||
|
* module id and version are: a value that somehow did not come from a validated manifest can only
|
||||||
|
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
|
||||||
|
* fragment — and a value that needed encoding simply fails to resolve against the registry.
|
||||||
|
*
|
||||||
|
* Like every other publication helper, this takes **identity only**. There is no parameter for a
|
||||||
|
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
|
||||||
|
* module package.
|
||||||
|
*/
|
||||||
|
export function moduleContributionArtifactUrl(
|
||||||
|
identity: ModuleUiIdentity,
|
||||||
|
contributionId: string,
|
||||||
|
): string {
|
||||||
|
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||||
|
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||||
|
* the installed one.
|
||||||
|
*
|
||||||
|
* They are deliberately indistinguishable. The route requires no authentication (see
|
||||||
|
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
|
||||||
|
* which modules an installation runs and at which versions. Collapsing them means a caller must
|
||||||
|
* already know both the exact module id and the exact version to learn anything at all.
|
||||||
|
*/
|
||||||
|
function notPublished(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "MODULE_UI_NOT_PUBLISHED" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artifact was validated at discovery and is not retrievable now — deleted, replaced by a
|
||||||
|
* directory, or unreadable. That is a server-side condition rather than a bad request, and often a
|
||||||
|
* transient one (an operator mid-upgrade), so it reports like every other "could not be determined"
|
||||||
|
* result in this application: a sanitized 503 with a fixed code. Never carries the artifact path,
|
||||||
|
* the module directory, the `errno`, or the caught error.
|
||||||
|
*/
|
||||||
|
function artifactUnavailable(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9110 §13.1.2 evaluation of `If-None-Match`: `*` matches any existing representation, and
|
||||||
|
* every listed entity-tag is compared with the **weak** comparison function, so a client echoing
|
||||||
|
* `W/"…"` still matches the strong tag this route issued. Anything unparsable simply fails to
|
||||||
|
* match, which costs a full 200 and can never produce a wrongly-fresh 304.
|
||||||
|
*/
|
||||||
|
function isNotModified(headerValue: string | undefined, etag: string): boolean {
|
||||||
|
if (headerValue === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (headerValue.trim() === "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return headerValue.split(",").some((candidate) => {
|
||||||
|
const value = candidate.trim();
|
||||||
|
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublishedArtifact {
|
||||||
|
readonly artifactPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
|
||||||
|
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
|
||||||
|
// Discovery validated this exact path earlier; the file may still have changed or vanished
|
||||||
|
// since. The response is produced from whatever a single read of that one path returns right
|
||||||
|
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
|
||||||
|
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
|
||||||
|
// something else.
|
||||||
|
let artifact: Buffer;
|
||||||
|
try {
|
||||||
|
artifact = await readFile(published.artifactPath);
|
||||||
|
} catch {
|
||||||
|
return artifactUnavailable(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
|
||||||
|
// validator can never describe a different revision than the body does.
|
||||||
|
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
|
||||||
|
if (isNotModified(c.req.header("if-none-match"), etag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(artifact, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": MODULE_UI_CACHE_CONTROL,
|
||||||
|
"Content-Type": MODULE_UI_CONTENT_TYPE,
|
||||||
|
ETag: etag,
|
||||||
|
// The declared type is the only type: a browser must not be allowed to sniff a module
|
||||||
|
// artifact into some other interpretation, and `import()` requires the JavaScript type
|
||||||
|
// anyway.
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModuleUiRoutesOptions {
|
||||||
|
/** The platform's publication policy, built by Core from the discovered, started module
|
||||||
|
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
|
||||||
|
* every byte it serves from this registry and nothing else. */
|
||||||
|
readonly registry: ModuleUiPublicationRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes installed modules' validated `entrypoints.ui` artifacts at a platform-owned browser
|
||||||
|
* URL, closing the one gap between Core's server-side artifact path and the generic loader's
|
||||||
|
* browser-side URL (ADR 0024, ADR 0025).
|
||||||
|
*
|
||||||
|
* ## The request never touches the filesystem
|
||||||
|
*
|
||||||
|
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||||
|
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||||
|
* `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||||
|
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
||||||
|
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
||||||
|
* are all unreachable by construction rather than by filtering. An installed module directory never
|
||||||
|
* becomes a static file root, and only the single artifact its manifest declares is reachable.
|
||||||
|
*
|
||||||
|
* ## No authentication, deliberately
|
||||||
|
*
|
||||||
|
* Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an
|
||||||
|
* operator reviewed and installed; it carries no credential, and the host — not the artifact —
|
||||||
|
* supplies the principal and permissions at mount time. Every module API stays behind
|
||||||
|
* `requirePermission` against the composed platform registry, and nothing about fetching this file
|
||||||
|
* changes a single authorization decision.
|
||||||
|
*
|
||||||
|
* Requiring a session would also break the thing it is meant to protect: a browser's dynamic
|
||||||
|
* `import()` uses `credentials: "same-origin"`, so a cross-origin import (the split-origin
|
||||||
|
* deployments `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support) sends no cookie and would fail
|
||||||
|
* — a failure that tests green on a server and only shows up in a real browser, exactly the class of
|
||||||
|
* error ADR 0024 exists to prevent. Working around it would mean fetching the bytes and importing a
|
||||||
|
* `blob:` URL, which costs a host its `script-src` CSP.
|
||||||
|
*
|
||||||
|
* The honest cost is stated in ADR 0025: an anonymous caller who already knows a module id **and**
|
||||||
|
* its exact installed version can download that module's UI bundle. That is information disclosure,
|
||||||
|
* not authority, and it is the reason the failure responses above are indistinguishable.
|
||||||
|
*
|
||||||
|
* ## The artifact is never executed
|
||||||
|
*
|
||||||
|
* This route reads bytes and writes bytes. It does not `import()`, evaluate, transform, minify,
|
||||||
|
* re-bundle, or rewrite the artifact, and a module UI whose top level throws is served exactly like
|
||||||
|
* any other. Core's rule that discovery never executes `entrypoints.ui` (ADR 0021) is unchanged, and
|
||||||
|
* publication does not become a second, quieter way to run browser code on the server.
|
||||||
|
*/
|
||||||
|
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(
|
||||||
|
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
|
||||||
|
async (c) => {
|
||||||
|
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
|
||||||
|
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
|
||||||
|
// UI artifact and on no others.
|
||||||
|
const published = options.registry.resolveContribution(
|
||||||
|
c.req.param("moduleId"),
|
||||||
|
c.req.param("version"),
|
||||||
|
c.req.param("contributionId"),
|
||||||
|
);
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||||
|
const published = options.registry.resolveDisplay(
|
||||||
|
c.req.param("moduleId"),
|
||||||
|
c.req.param("version"),
|
||||||
|
);
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
|
||||||
|
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -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,828 @@
|
|||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
displayHostAssets =
|
||||||
|
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||||
|
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||||
|
// so a missing shell build fails here, before any module code is imported.
|
||||||
|
// The page learns its API origin from the API that serves it, so one build serves either
|
||||||
|
// topology (ADR 0045).
|
||||||
|
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
|
||||||
|
} catch (error) {
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
|
await safeCloseDatabase(databaseHandle);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||||
|
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
||||||
|
// handle to stop here, only the pool this failure leaves open.
|
||||||
|
//
|
||||||
|
// Only eligible modules are started, and startup remains all-or-nothing across *that* set: an
|
||||||
|
// ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no
|
||||||
|
// routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves
|
||||||
|
// discovery's topological order, so a started module's required dependencies still precede it.
|
||||||
|
let moduleRuntime: StartedModuleRuntime;
|
||||||
|
try {
|
||||||
|
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||||
|
configurations: eligibility.configurations,
|
||||||
|
storages: moduleStorages,
|
||||||
|
...(options.moduleHookTimeoutMsOverride === undefined
|
||||||
|
? {}
|
||||||
|
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
|
await safeCloseDatabase(databaseHandle);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
|
||||||
|
// `503` while the instance still finishes in-flight requests.
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
let app: Hono;
|
||||||
|
try {
|
||||||
|
app = createApiApp({
|
||||||
|
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),
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
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,131 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||||
|
dashboardHostAssetUrls: Object.freeze({
|
||||||
|
html: "file:///surface/browser/index.html",
|
||||||
|
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||||
|
DashboardHostAssetError,
|
||||||
|
loadDashboardHostAssets,
|
||||||
|
renderDashboardPage,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
const API_ORIGIN = "https://api.example.test";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard host asset loading", () => {
|
||||||
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
|
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
|
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||||
|
const script = "export const dashboardHost = true;\n";
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||||
|
|
||||||
|
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||||
|
|
||||||
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
|
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||||
|
]);
|
||||||
|
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||||
|
expect(assets).toEqual({
|
||||||
|
html: rendered,
|
||||||
|
htmlEtag: sha256Etag(rendered),
|
||||||
|
script,
|
||||||
|
scriptEtag: sha256Etag(script),
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||||
|
readFileMock.mockRejectedValueOnce(
|
||||||
|
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||||
|
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||||
|
expect(publicText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the script is missing even though the page was read", async () => {
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||||
|
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||||
|
|
||||||
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
|
DashboardHostAssetError,
|
||||||
|
);
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||||
|
for (const [html, script] of [
|
||||||
|
["", "export {};"],
|
||||||
|
["<html></html>", " \n"],
|
||||||
|
] as const) {
|
||||||
|
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
|
||||||
|
for (const html of [
|
||||||
|
"<!doctype html>",
|
||||||
|
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
|
||||||
|
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||||
|
]) {
|
||||||
|
readFileMock.mockReset();
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||||
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
|
DashboardHostAssetError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard page API origin (ADR 0045)", () => {
|
||||||
|
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
|
||||||
|
|
||||||
|
it("writes the API's origin into the one placeholder, in either topology", () => {
|
||||||
|
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
|
||||||
|
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
|
||||||
|
);
|
||||||
|
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
|
||||||
|
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://api.example.test/",
|
||||||
|
"https://api.example.test/api",
|
||||||
|
'https://api.example.test"><script>',
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"",
|
||||||
|
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
|
||||||
|
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
|
||||||
|
// while answering a request — the hallmark of a static directory — fails these tests.
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
DashboardHostConfigurationError,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
|
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("dashboard.page.read");
|
||||||
|
|
||||||
|
const PAGE_HTML =
|
||||||
|
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
|
||||||
|
const SCRIPT = "export const dashboard = 'built shell';\n";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||||
|
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
options: {
|
||||||
|
readonly assets?: DashboardHostAssets;
|
||||||
|
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||||
|
health: readyHealth(),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPageSecurityHeaders(response: Response): void {
|
||||||
|
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
||||||
|
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||||
|
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
|
||||||
|
expect(response.status, label).toBe(404);
|
||||||
|
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET / (the Dashboard page)", () => {
|
||||||
|
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
// The one CSP directive is the only one: the full policy is a separate decision.
|
||||||
|
expect(response.headers.get("content-security-policy")).not.toContain(";");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": ifNoneMatch },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status, ifNoneMatch).toBe(304);
|
||||||
|
expect(await response.text(), ifNoneMatch).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the full page again when the browser holds a different build's validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": sha256Etag("an older build") },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
|
||||||
|
const { app, evaluator, runtime } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
expect(response.headers.get("set-cookie")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||||
|
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(SCRIPT);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(304);
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept the page's validator as the script's", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": PAGE_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard host boundaries", () => {
|
||||||
|
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/index.html",
|
||||||
|
"/app.js",
|
||||||
|
"/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/",
|
||||||
|
"/assets/ekklesia-dashboard",
|
||||||
|
"/assets/ekklesia-dashboard/app.js/",
|
||||||
|
"/assets/ekklesia-dashboard/index.html",
|
||||||
|
"/assets/",
|
||||||
|
"/assets/ekklesia-dashboard/APP.JS",
|
||||||
|
"/dashboard",
|
||||||
|
"/login",
|
||||||
|
]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path), path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
|
||||||
|
"/%2e%2e/%2e%2e/etc/passwd",
|
||||||
|
"/../../etc/passwd",
|
||||||
|
"//assets/ekklesia-dashboard/app.js",
|
||||||
|
"/assets//ekklesia-dashboard/app.js",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%00.map",
|
||||||
|
]) {
|
||||||
|
const response = await app.request(path);
|
||||||
|
expect(await response.text(), path).not.toContain(SCRIPT);
|
||||||
|
expect(response.status, path).toBe(404);
|
||||||
|
}
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches the filesystem while answering, even across every served path", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
await app.request("/assets/ekklesia-dashboard/app.js.map");
|
||||||
|
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(await head.text()).toBe("");
|
||||||
|
|
||||||
|
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const { app } = buildApp({
|
||||||
|
moduleRoutes: [
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "dashboard",
|
||||||
|
path: "/",
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
|
||||||
|
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
|
||||||
|
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
|
||||||
|
// No display is published here, so the display host's script is absent rather than answered
|
||||||
|
// by the Dashboard's.
|
||||||
|
await expectSanitizedNotFound(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH),
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
);
|
||||||
|
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
|
||||||
|
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||||
|
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||||
|
|
||||||
|
for (const assets of [
|
||||||
|
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||||
|
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||||
|
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||||
|
]) {
|
||||||
|
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationStore,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { DashboardConfiguration } from "@ekklesia/core/dashboard";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
discoverModules,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
const viewerId = parseEkklesiaUserId("33333333-3333-4333-8333-333333333333");
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly withUi?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${fixture.id}`,
|
||||||
|
type: "module",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
|
},
|
||||||
|
id: fixture.id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: fixture.name,
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||||
|
);
|
||||||
|
if (fixture.withUi !== false) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallOptions {
|
||||||
|
readonly fixtures?: readonly ModuleFixture[];
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
readonly platformOwner?: boolean;
|
||||||
|
readonly configuration?: Partial<DashboardConfiguration>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFixtures: readonly ModuleFixture[] = [
|
||||||
|
{ id: "video", name: "Video" },
|
||||||
|
{ id: "livestream", name: "Livestream" },
|
||||||
|
{ id: "calendar", name: "Calendar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dashboard-routes-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
for (const fixture of options.fixtures ?? defaultFixtures) {
|
||||||
|
await createModule(modulesDirectory, fixture);
|
||||||
|
}
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluator = createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async () => false },
|
||||||
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
|
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: Object.freeze({
|
||||||
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
|
}) as LegacyModuleRuntimeServices,
|
||||||
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(): EkklesiaPrincipal {
|
||||||
|
return createAuthenticatedPrincipal(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationEntry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly module: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly ui: { readonly url: string };
|
||||||
|
readonly api: { readonly baseUrl: string };
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationBody {
|
||||||
|
readonly groups: ReadonlyArray<{
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly order: number;
|
||||||
|
readonly modules: readonly NavigationEntry[];
|
||||||
|
}>;
|
||||||
|
readonly ungrouped: readonly NavigationEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigationOf(app: Hono): Promise<NavigationBody> {
|
||||||
|
const response = await app.request(DASHBOARD_NAVIGATION_PATH);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
return (await response.json()) as NavigationBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An administrator: a platform owner, who passes the evaluator's owner bypass for the restricted
|
||||||
|
* manage permission exactly as in production. */
|
||||||
|
async function administrator(configuration: Partial<DashboardConfiguration> = {}) {
|
||||||
|
return install({ configuration, platformOwner: true, principal: signedIn() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutate(app: Hono, method: "PUT" | "DELETE", url: string, body?: unknown) {
|
||||||
|
return app.request(url, {
|
||||||
|
...(body === undefined
|
||||||
|
? {}
|
||||||
|
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
|
||||||
|
headers: {
|
||||||
|
origin: trustedOrigin,
|
||||||
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard navigation — reading", () => {
|
||||||
|
it("shows every mountable module as ungrouped when nothing is configured", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups).toEqual([]);
|
||||||
|
// A fresh installation is immediately usable: no initialization write, no manual database edit.
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||||
|
"Calendar",
|
||||||
|
"Livestream",
|
||||||
|
"Video",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the module's own manifest name, never its id", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).not.toContain("livestream");
|
||||||
|
expect(navigation.ungrouped.find((entry) => entry.module.id === "livestream")?.name).toBe(
|
||||||
|
"Livestream",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups and orders exactly as the stored configuration says", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
configuration: {
|
||||||
|
groups: [
|
||||||
|
{ id: "planning", label: "Planning", sortOrder: 20 },
|
||||||
|
{ id: "media", label: "Media", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
placements: [
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 20 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||||
|
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
navigation.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
|
||||||
|
).toEqual([
|
||||||
|
["Media", ["Video", "Livestream"]],
|
||||||
|
["Planning", ["Calendar"]],
|
||||||
|
]);
|
||||||
|
expect(navigation.ungrouped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embeds the generic mount descriptor unchanged inside each entry", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
const livestream = navigation.ungrouped.find((entry) => entry.module.id === "livestream");
|
||||||
|
|
||||||
|
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
||||||
|
expect(livestream?.module).toEqual({
|
||||||
|
contributions: [],
|
||||||
|
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||||
|
display: null,
|
||||||
|
id: "livestream",
|
||||||
|
permissions: [],
|
||||||
|
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
||||||
|
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees exactly with the generic inventory about what is mountable", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
configuration: {
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||||
|
},
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
|
modules: ReadonlyArray<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationModules = [
|
||||||
|
...navigation.groups.flatMap((group) => group.modules),
|
||||||
|
...navigation.ungrouped,
|
||||||
|
].map((entry) => entry.module);
|
||||||
|
// Both derive from one publication registry through one shared description, so they cannot
|
||||||
|
// disagree about what exists or about any URL.
|
||||||
|
expect(navigationModules.map((module) => module.id).sort()).toEqual(
|
||||||
|
inventory.modules.map((module) => module.id).sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the generic inventory free of presentation metadata", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
|
||||||
|
|
||||||
|
// The host-neutral contract a standalone host consumes must not learn Ekklesia has folders or
|
||||||
|
// display names. That layering is the whole reason navigation is a separate route.
|
||||||
|
for (const field of ["name", "label", "group", "order", "ungrouped"]) {
|
||||||
|
expect(body, field).not.toContain(`"${field}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a module that is no longer installed, without breaking navigation", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
configuration: {
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "removed-module", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fixtures: [{ id: "video", name: "Video" }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups[0]?.modules.map((entry) => entry.module.id)).toEqual(["video"]);
|
||||||
|
expect(JSON.stringify(navigation)).not.toContain("removed-module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a newly installed module visible alongside an existing arrangement", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
configuration: {
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||||
|
},
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
|
||||||
|
// Calendar and Livestream were never configured and are still reachable.
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Livestream"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes a module that ships no UI, exactly like the inventory does", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{ id: "video", name: "Video" },
|
||||||
|
{ id: "roster", name: "Roster", withUi: false },
|
||||||
|
],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(JSON.stringify(navigation)).not.toContain("Roster");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaks no filesystem path, package root, or manifest internal", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const body = await (await app.request(DASHBOARD_NAVIGATION_PATH)).text();
|
||||||
|
|
||||||
|
expect(body).not.toContain(tmpdir());
|
||||||
|
expect(body).not.toContain("dist");
|
||||||
|
expect(body).not.toContain("@test/module-");
|
||||||
|
expect(body).not.toContain("ekklesia.module.json");
|
||||||
|
expect(body).not.toContain("entrypoints");
|
||||||
|
expect(body).not.toContain("manifestVersion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is per-principal, uncached, and refused to anonymous callers", async () => {
|
||||||
|
const { app: anonymous } = await install();
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
const denied = await anonymous.request(DASHBOARD_NAVIGATION_PATH);
|
||||||
|
const allowed = await app.request(DASHBOARD_NAVIGATION_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(denied.status).toBe(403);
|
||||||
|
expect(await denied.text()).not.toContain("Livestream");
|
||||||
|
expect(allowed.headers.get("cache-control")).toBe("no-store");
|
||||||
|
const vary = allowed.headers.get("vary")?.toLowerCase() ?? "";
|
||||||
|
expect(vary).toContain("origin");
|
||||||
|
expect(vary).toContain("cookie");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard navigation — organizing", () => {
|
||||||
|
it("creates a group and places a module, and both persist into the resolved view", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
label: "Media",
|
||||||
|
order: 10,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||||
|
groupId: "media",
|
||||||
|
order: 10,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups.map((group) => group.label)).toEqual(["Media"]);
|
||||||
|
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Video"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames a group without moving anything", async () => {
|
||||||
|
const { app } = await administrator({
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
label: "Sunday Production",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups[0]).toMatchObject({ id: "media", label: "Sunday Production" });
|
||||||
|
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a module between groups and changes only its placement", async () => {
|
||||||
|
const { app } = await administrator({
|
||||||
|
groups: [
|
||||||
|
{ id: "media", label: "Media", sortOrder: 10 },
|
||||||
|
{ id: "sunday-production", label: "Sunday Production", sortOrder: 5 },
|
||||||
|
],
|
||||||
|
placements: [
|
||||||
|
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
|
||||||
|
{ groupId: "media", moduleId: "video", sortOrder: 20 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const before = await navigationOf(app);
|
||||||
|
const livestreamBefore = before.groups
|
||||||
|
.flatMap((group) => group.modules)
|
||||||
|
.find((entry) => entry.module.id === "livestream");
|
||||||
|
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||||
|
groupId: "sunday-production",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
const after = await navigationOf(app);
|
||||||
|
const livestreamAfter = after.groups
|
||||||
|
.flatMap((group) => group.modules)
|
||||||
|
.find((entry) => entry.module.id === "livestream");
|
||||||
|
|
||||||
|
// The one property this whole pass exists to guarantee: the module — its artifact URL, its API
|
||||||
|
// root, its permissions, its version, its name — is byte-identical across the move.
|
||||||
|
expect(livestreamAfter).toEqual(livestreamBefore);
|
||||||
|
expect(
|
||||||
|
after.groups.find((group) => group.id === "sunday-production")?.modules.map((e) => e.name),
|
||||||
|
).toEqual(["Livestream"]);
|
||||||
|
expect(after.groups.find((group) => group.id === "media")?.modules.map((e) => e.name)).toEqual([
|
||||||
|
"Video",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ungroups a module deliberately", async () => {
|
||||||
|
const { app } = await administrator({
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
|
||||||
|
groupId: null,
|
||||||
|
order: 5,
|
||||||
|
});
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(navigation.groups[0]?.modules).toEqual([]);
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||||
|
"Video",
|
||||||
|
"Calendar",
|
||||||
|
"Livestream",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ungroups a deleted group's modules rather than losing them", async () => {
|
||||||
|
const { app } = await administrator({
|
||||||
|
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
|
||||||
|
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await mutate(app, "DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`);
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
expect(deleted.status).toBe(204);
|
||||||
|
expect(navigation.groups).toEqual([]);
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
|
||||||
|
"Video",
|
||||||
|
"Calendar",
|
||||||
|
"Livestream",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a placement naming a group that does not exist", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
|
||||||
|
groupId: "not-a-group",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "DASHBOARD_GROUP_NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a body carrying anything beyond the declared fields", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
const bodies: readonly unknown[] = [
|
||||||
|
{ label: "Media", order: 10, parentGroupId: "root" },
|
||||||
|
{ label: "Media", order: 10, ownerModuleId: "video" },
|
||||||
|
{ label: "Media" },
|
||||||
|
{ label: "", order: 10 },
|
||||||
|
{ label: "Media", order: -1 },
|
||||||
|
{ label: "Media", order: 1.5 },
|
||||||
|
{ label: 42, order: 10 },
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
for (const body of bodies) {
|
||||||
|
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, body);
|
||||||
|
expect(response.status, JSON.stringify(body)).toBe(400);
|
||||||
|
expect(await response.json(), JSON.stringify(body)).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed group identifier in the path", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/Not%20Valid`, {
|
||||||
|
label: "Media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard navigation — authority", () => {
|
||||||
|
it("refuses organizing to a signed-in user without the manage permission", async () => {
|
||||||
|
// Reading works for any signed-in user; organizing does not.
|
||||||
|
const { app } = await install({ principal: signedIn() });
|
||||||
|
|
||||||
|
expect((await app.request(DASHBOARD_NAVIGATION_PATH)).status).toBe(200);
|
||||||
|
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
label: "Media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses organizing from an untrusted origin, before authorization even runs", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
const response = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||||
|
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses organizing to an anonymous caller", async () => {
|
||||||
|
const { app } = await install();
|
||||||
|
|
||||||
|
for (const [method, url, body] of [
|
||||||
|
["PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10 }],
|
||||||
|
["DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, undefined],
|
||||||
|
["PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 0 }],
|
||||||
|
] as const) {
|
||||||
|
const response = await mutate(app, method, url, body);
|
||||||
|
expect(response.status, `${method} ${url}`).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants no module authority to an administrator who reorganizes the Dashboard", async () => {
|
||||||
|
// The platform owner holds `core.dashboard.navigation.manage` through the owner bypass. That
|
||||||
|
// says nothing about any module: the hints in the navigation view are still evaluated per
|
||||||
|
// module permission, and every module route stays enforced on its own.
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
label: "Media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
|
||||||
|
groupId: "media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
const navigation = await navigationOf(app);
|
||||||
|
|
||||||
|
const livestream = navigation.groups[0]?.modules[0];
|
||||||
|
// The fixture modules declare no permissions at all, so an owner still holds none for them:
|
||||||
|
// moving a module into a folder cannot manufacture a permission hint, let alone a grant.
|
||||||
|
expect(livestream?.module.permissions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes no method the navigation contract does not define", async () => {
|
||||||
|
const { app } = await administrator();
|
||||||
|
|
||||||
|
for (const method of ["POST", "PATCH"] as const) {
|
||||||
|
const response = await app.request(DASHBOARD_NAVIGATION_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
expect(response.status, method).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
createAuthorizationDrizzleDatabase,
|
||||||
|
createInitialPlatformOwner,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device pairing end to end, against real PostgreSQL and the real HTTP transport (ADR 0036).
|
||||||
|
*
|
||||||
|
* The whole point is that none of this is mocked. A screen with no identity asks to be paired, a
|
||||||
|
* real administrator signs in through the real login transport and approves it, the host issues a
|
||||||
|
* credential in a real `Set-Cookie`, and the resulting device opens a real display descriptor and a
|
||||||
|
* real realtime stream — and is then revoked and loses all of it. Anything short of that would
|
||||||
|
* prove that rows were written, not that a television in a foyer works.
|
||||||
|
*/
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
const trackedDirectories: string[] = [];
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
const identityBaseUrl = "https://identity.example.test";
|
||||||
|
const dashboardOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
const ownerPassword = "correct horse battery staple";
|
||||||
|
const displayPermission = "announcements.slideshow.view";
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const url = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof url !== "string" || url.trim() === "") {
|
||||||
|
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(name: string): string {
|
||||||
|
const url = new URL(baseDatabaseUrl());
|
||||||
|
url.pathname = `/${name}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<T extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
return (await client.query<T>(text, [...values])).rows;
|
||||||
|
} catch {
|
||||||
|
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function query<T extends QueryResultRow>(
|
||||||
|
url: string,
|
||||||
|
text: string,
|
||||||
|
values: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
return await safeQuery<T>(client, text, values);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A display-capable module: one display entrypoint, one restricted permission protecting it, and
|
||||||
|
* one realtime channel gated by that same permission. */
|
||||||
|
async function createDisplayModuleDirectory(): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
|
||||||
|
trackedDirectories.push(directory);
|
||||||
|
const moduleDirectory = path.join(directory, "announcements");
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: "@test/module-announcements",
|
||||||
|
type: "module",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
|
entrypoints: {
|
||||||
|
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
},
|
||||||
|
id: "announcements",
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: "Announcements fixture",
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: displayPermission,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default {
|
||||||
|
id: "announcements",
|
||||||
|
version: "1.0.0",
|
||||||
|
register({ realtime }) {
|
||||||
|
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
|
||||||
|
},
|
||||||
|
start({ realtime }) {
|
||||||
|
realtime.publish("state", { slides: [] });
|
||||||
|
},
|
||||||
|
stop() {},
|
||||||
|
};\n`,
|
||||||
|
),
|
||||||
|
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
|
||||||
|
]);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMigratedDatabase(label: string): Promise<string> {
|
||||||
|
databaseCounter += 1;
|
||||||
|
const name =
|
||||||
|
`ekk_device_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||||
|
const admin = await openAdministrativeClient();
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `create database "${name}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
const url = databaseUrl(name);
|
||||||
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModules,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModules, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPlatform(url: string, modulesDirectory: string): Promise<RunningApi> {
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
// The production posture: no public signup anywhere in this suite.
|
||||||
|
publicSignupEnabled: false,
|
||||||
|
secret: "integration-test-secret-0123456789abcdefgh",
|
||||||
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
return startApi(config, {
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
rateLimitOverride: { max: 1000, window: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiOrigin(running: RunningApi): string {
|
||||||
|
const address = running.server.address();
|
||||||
|
if (!address || typeof address !== "object") {
|
||||||
|
throw new IntegrationHarnessError("The API integration server has no bound address.");
|
||||||
|
}
|
||||||
|
return `http://127.0.0.1:${address.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-`
|
||||||
|
* whenever the configured base URL is https — which this harness's is. */
|
||||||
|
function cookiePair(response: Response, name: string): string | undefined {
|
||||||
|
for (const header of response.headers.getSetCookie()) {
|
||||||
|
const pair = header.split(";")[0]?.trim() ?? "";
|
||||||
|
const cookieName = pair.slice(0, Math.max(0, pair.indexOf("=")));
|
||||||
|
if (cookieName === name || cookieName.endsWith(name)) {
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookieFor(response: Response, name: string): string | undefined {
|
||||||
|
return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOwner(url: string, origin: string): Promise<string> {
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await createInitialPlatformOwner(
|
||||||
|
{
|
||||||
|
authorizationDb: authorization.db,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
identityDb: identity.db,
|
||||||
|
},
|
||||||
|
{ email, name: "Platform Owner", password: ownerPassword },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([authorization.close(), identity.close()]);
|
||||||
|
}
|
||||||
|
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
|
body: JSON.stringify({ email, password: ownerPassword }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const cookie = cookiePair(signIn, "better-auth.session_token");
|
||||||
|
if (signIn.status !== 200 || cookie === undefined) {
|
||||||
|
throw new IntegrationHarnessError("The platform owner could not sign in.");
|
||||||
|
}
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Platform {
|
||||||
|
readonly api: RunningApi;
|
||||||
|
readonly origin: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly ownerCookie: string;
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function platform(label: string): Promise<Platform> {
|
||||||
|
const url = await createMigratedDatabase(label);
|
||||||
|
const modulesDirectory = await createDisplayModuleDirectory();
|
||||||
|
const api = await startPlatform(url, modulesDirectory);
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
const ownerCookie = await createOwner(url, origin);
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
close: async () => {
|
||||||
|
await api.shutdown();
|
||||||
|
},
|
||||||
|
origin,
|
||||||
|
ownerCookie,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device's own browser: no human session, only whatever cookies the host has set on it. */
|
||||||
|
class DeviceBrowser {
|
||||||
|
#cookies = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(private readonly origin: string) {}
|
||||||
|
|
||||||
|
absorb(response: Response): void {
|
||||||
|
for (const header of response.headers.getSetCookie()) {
|
||||||
|
const pair = header.split(";")[0] ?? "";
|
||||||
|
const separator = pair.indexOf("=");
|
||||||
|
if (separator === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = pair.slice(0, separator).trim();
|
||||||
|
const value = pair.slice(separator + 1).trim();
|
||||||
|
if (value.length === 0 || /max-age=0/iu.test(header)) {
|
||||||
|
this.#cookies.delete(name);
|
||||||
|
} else {
|
||||||
|
this.#cookies.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header(): string {
|
||||||
|
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
has(name: string): boolean {
|
||||||
|
return this.#cookies.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
overwrite(name: string, value: string): void {
|
||||||
|
this.#cookies.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const cookie = this.header();
|
||||||
|
const response = await fetch(`${this.origin}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
...(cookie.length > 0 ? { cookie } : {}),
|
||||||
|
...(init.headers as Record<string, string> | undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.absorb(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
post(path: string, body: unknown): Promise<Response> {
|
||||||
|
return this.request(path, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminRequest(
|
||||||
|
origin: string,
|
||||||
|
cookie: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
overrides: RequestInit = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`${origin}${path}`, {
|
||||||
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
|
headers: {
|
||||||
|
cookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||||
|
...(overrides.headers as Record<string, string> | undefined),
|
||||||
|
},
|
||||||
|
method: body === undefined ? "GET" : "POST",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives a screen from "nobody has paired me" to "I hold a device session". */
|
||||||
|
async function pairDevice(
|
||||||
|
platform: Platform,
|
||||||
|
device: DeviceBrowser,
|
||||||
|
deviceName = "Foyer TV",
|
||||||
|
): Promise<{ readonly code: string }> {
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
if (requested.status !== 201) {
|
||||||
|
throw new IntegrationHarnessError("The pairing request was refused.");
|
||||||
|
}
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
const approved = await adminRequest(
|
||||||
|
platform.origin,
|
||||||
|
platform.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code, deviceName },
|
||||||
|
);
|
||||||
|
if (approved.status !== 200) {
|
||||||
|
throw new IntegrationHarnessError("The pairing approval was refused.");
|
||||||
|
}
|
||||||
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||||
|
if (((await claimed.json()) as { status: string }).status !== "paired") {
|
||||||
|
throw new IntegrationHarnessError("The device was not issued a session.");
|
||||||
|
}
|
||||||
|
return { code };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
|
if (admin) {
|
||||||
|
try {
|
||||||
|
for (const name of trackedDatabases) {
|
||||||
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const directory of trackedDirectories.splice(0)) {
|
||||||
|
await rm(directory, { force: true, recursive: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("display device pairing, end to end", () => {
|
||||||
|
it("takes an unpaired screen all the way to a working display, and back off again", async () => {
|
||||||
|
const running = await platform("e2e");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
// 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it
|
||||||
|
// needs in order to show a code is served.
|
||||||
|
expect((await device.request("/display/announcements")).status).toBe(200);
|
||||||
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
||||||
|
|
||||||
|
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
expect(requested.status).toBe(201);
|
||||||
|
const body = (await requested.json()) as Record<string, unknown>;
|
||||||
|
expect(body.code).toMatch(/^[0-9]{6}$/u);
|
||||||
|
// The claim secret is never in the body — only in a cookie script cannot read.
|
||||||
|
expect(JSON.stringify(body)).not.toContain("claim");
|
||||||
|
const claimCookie = setCookieFor(requested, "ekklesia_device_pairing");
|
||||||
|
expect(claimCookie).toMatch(/HttpOnly/iu);
|
||||||
|
|
||||||
|
// 3. Nothing has happened yet: polling says pending, and the display is still refused.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
).status,
|
||||||
|
).toBe("pending");
|
||||||
|
|
||||||
|
// 4. The administrator looks the code up, sees what it wants, and approves it by name.
|
||||||
|
const lookup = await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/lookups",
|
||||||
|
{ code: body.code },
|
||||||
|
);
|
||||||
|
expect(lookup.status).toBe(200);
|
||||||
|
expect(await lookup.json()).toMatchObject({
|
||||||
|
moduleId: "announcements",
|
||||||
|
permissionId: displayPermission,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code: body.code, deviceName: "Foyer TV" },
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
// 5. The screen collects its credential — through Set-Cookie, never through JSON.
|
||||||
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||||
|
expect(await claimed.json()).toEqual({ status: "paired" });
|
||||||
|
const sessionCookie = setCookieFor(claimed, "ekklesia_device_session");
|
||||||
|
expect(sessionCookie).toMatch(/HttpOnly/iu);
|
||||||
|
expect(sessionCookie).toMatch(/SameSite=Strict/iu);
|
||||||
|
expect(sessionCookie).toMatch(/Path=\//u);
|
||||||
|
expect(sessionCookie).not.toMatch(/Domain=/iu);
|
||||||
|
// The spent pairing cookie is cleared rather than left on the device.
|
||||||
|
expect(device.has("ekklesia_device_pairing")).toBe(false);
|
||||||
|
expect(device.has("ekklesia_device_session")).toBe(true);
|
||||||
|
|
||||||
|
// 6. The display now works, and reports the device as its viewer.
|
||||||
|
const descriptor = await device.request("/api/v1/module-displays/announcements");
|
||||||
|
expect(descriptor.status).toBe(200);
|
||||||
|
const descriptorBody = (await descriptor.json()) as {
|
||||||
|
viewer: { kind: string; deviceId?: string };
|
||||||
|
module: { permissions: string[] };
|
||||||
|
};
|
||||||
|
expect(descriptorBody.viewer.kind).toBe("device");
|
||||||
|
expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u);
|
||||||
|
// Exactly the one permission it was paired for.
|
||||||
|
expect(descriptorBody.module.permissions).toEqual([displayPermission]);
|
||||||
|
|
||||||
|
// 7. Realtime, through the ordinary permission-gated channel.
|
||||||
|
const stream = await device.request("/api/v1/module-realtime/announcements/state", {
|
||||||
|
headers: { accept: "text/event-stream" },
|
||||||
|
});
|
||||||
|
expect(stream.status).toBe(200);
|
||||||
|
expect(stream.headers.get("content-type")).toContain("text/event-stream");
|
||||||
|
await stream.body?.cancel();
|
||||||
|
|
||||||
|
// 8. The device is still nobody as far as the Dashboard is concerned.
|
||||||
|
expect(await (await device.request("/api/v1/principal")).json()).toEqual({
|
||||||
|
kind: "anonymous",
|
||||||
|
});
|
||||||
|
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
|
||||||
|
expect((await device.request("/api/v1/devices")).status).toBe(403);
|
||||||
|
|
||||||
|
// 9. The administrator revokes it.
|
||||||
|
const devices = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
|
||||||
|
expect(devices.devices).toHaveLength(1);
|
||||||
|
expect(devices.devices[0]?.name).toBe("Foyer TV");
|
||||||
|
expect(devices.devices[0]?.permissions).toEqual([displayPermission]);
|
||||||
|
const revoked = await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${devices.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(revoked.status).toBe(204);
|
||||||
|
|
||||||
|
// 10. Everything it could do, it can no longer do — while still holding its cookie.
|
||||||
|
expect(device.has("ekklesia_device_session")).toBe(true);
|
||||||
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await device.request("/api/v1/module-realtime/announcements/state", {
|
||||||
|
headers: { accept: "text/event-stream" },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
// And the screen can start over: the page it needs for a new code is still served.
|
||||||
|
expect((await device.request("/display/announcements")).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores no usable credential, and puts none in any response body", async () => {
|
||||||
|
const running = await platform("secrets");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
expect(credential.length).toBeGreaterThan(20);
|
||||||
|
|
||||||
|
const stored = await query<{ credential_hash: string; name: string }>(
|
||||||
|
running.url,
|
||||||
|
`select credential_hash, name from ekklesia_core_device`,
|
||||||
|
);
|
||||||
|
expect(stored).toHaveLength(1);
|
||||||
|
// The raw credential is nowhere in the database — only an irreversible digest of it.
|
||||||
|
expect(stored[0]?.credential_hash).not.toContain(credential);
|
||||||
|
expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u);
|
||||||
|
|
||||||
|
const everything = JSON.stringify(
|
||||||
|
await query(running.url, `select * from ekklesia_core_device_pairing_request`),
|
||||||
|
);
|
||||||
|
expect(everything).not.toContain(credential);
|
||||||
|
|
||||||
|
// The descriptor a module's UI can read carries no credential either.
|
||||||
|
const descriptor = await device.request("/api/v1/module-displays/announcements");
|
||||||
|
expect(await descriptor.text()).not.toContain(credential);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pairing cannot be talked into anything", () => {
|
||||||
|
it("derives the permission from the display, never from the request", async () => {
|
||||||
|
const running = await platform("derive");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
// The browser tries to name its own permission and its own module.
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
permission: "core.devices.manage",
|
||||||
|
permissionId: "core.devices.manage",
|
||||||
|
});
|
||||||
|
expect(requested.status).toBe(201);
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
|
||||||
|
const lookup = await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/lookups",
|
||||||
|
{ code },
|
||||||
|
);
|
||||||
|
// The host recorded the display's own declared permission and ignored the rest.
|
||||||
|
expect(await lookup.json()).toMatchObject({ permissionId: displayPermission });
|
||||||
|
|
||||||
|
const stored = await query<{ permission_id: string }>(
|
||||||
|
running.url,
|
||||||
|
`select permission_id from ekklesia_core_device_pairing_request`,
|
||||||
|
);
|
||||||
|
expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to start pairing for a module that has no display", async () => {
|
||||||
|
const running = await platform("nodisplay");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
for (const moduleId of ["calendar", "core", "../announcements", ""]) {
|
||||||
|
const response = await device.post("/api/v1/device-pairing/requests", { moduleId });
|
||||||
|
expect(response.status, moduleId).toBeGreaterThanOrEqual(400);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
await query(running.url, `select id from ekklesia_core_device_pairing_request`),
|
||||||
|
).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues nothing to someone who knows only the code", async () => {
|
||||||
|
const running = await platform("codeonly");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
const onlooker = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{
|
||||||
|
code,
|
||||||
|
deviceName: "Foyer TV",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Someone who read the code off the television — and even guessed the request exists —
|
||||||
|
// holds no claim cookie, so there is nothing for them to collect.
|
||||||
|
const stolen = await onlooker.post("/api/v1/device-pairing/claims", {});
|
||||||
|
expect(stolen.status).toBe(400);
|
||||||
|
expect(onlooker.has("ekklesia_device_session")).toBe(false);
|
||||||
|
|
||||||
|
// The real screen still gets its credential.
|
||||||
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||||
|
expect(await claimed.json()).toEqual({ status: "paired" });
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approval is an administrator's act", () => {
|
||||||
|
it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => {
|
||||||
|
const running = await platform("approve");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
|
||||||
|
// Anonymous.
|
||||||
|
const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, {
|
||||||
|
body: JSON.stringify({ code, deviceName: "Intruder TV" }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(anonymous.status).toBe(403);
|
||||||
|
|
||||||
|
// The paired-device cookie is not an administrator either — a device can never approve
|
||||||
|
// another device, or itself.
|
||||||
|
const selfApproval = await device.post("/api/v1/device-pairing/approvals", {
|
||||||
|
code,
|
||||||
|
deviceName: "Self TV",
|
||||||
|
});
|
||||||
|
expect(selfApproval.status).toBe(403);
|
||||||
|
|
||||||
|
// A real administrator from an untrusted origin: CSRF policy applies before anything else.
|
||||||
|
const crossOrigin = await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code, deviceName: "Foyer TV" },
|
||||||
|
{ headers: { origin: untrustedOrigin } },
|
||||||
|
);
|
||||||
|
expect(crossOrigin.status).toBe(403);
|
||||||
|
|
||||||
|
// Nothing was approved by any of that.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
).status,
|
||||||
|
).toBe("pending");
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot approve the same code twice, and cannot mint two credentials from one", async () => {
|
||||||
|
const running = await platform("single");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code, deviceName: "Foyer TV" },
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
// A second approval of the same code finds nothing pending.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code, deviceName: "Second TV" },
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
|
||||||
|
// Two claims race for one approved request; exactly one credential may exist.
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
device.post("/api/v1/device-pairing/claims", {}),
|
||||||
|
device.post("/api/v1/device-pairing/claims", {}),
|
||||||
|
]);
|
||||||
|
const statuses = [
|
||||||
|
((await first.json()) as { status: string }).status,
|
||||||
|
((await second.json()) as { status: string }).status,
|
||||||
|
];
|
||||||
|
expect(statuses.filter((status) => status === "paired")).toHaveLength(1);
|
||||||
|
expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => {
|
||||||
|
const running = await platform("repair");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device, "Foyer TV");
|
||||||
|
const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
const listed = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string }> };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
await pairDevice(running, device, "Foyer TV (replaced)");
|
||||||
|
const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
expect(secondCredential).not.toBe(firstCredential);
|
||||||
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
|
||||||
|
|
||||||
|
// Re-pairing creates a new identity rather than reviving the revoked one, so the old
|
||||||
|
// credential can never come back — presenting it again is simply anonymous.
|
||||||
|
const revokedBrowser = new DeviceBrowser(running.origin);
|
||||||
|
revokedBrowser.overwrite("ekklesia_device_session", firstCredential);
|
||||||
|
expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
const devices = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ revoked: boolean }> };
|
||||||
|
expect(devices.devices).toHaveLength(2);
|
||||||
|
expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||||
|
const running = await platform("mixed");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const deviceCredential = device.header();
|
||||||
|
|
||||||
|
// A browser presenting both: the person still wins, and stays a person.
|
||||||
|
const both = await fetch(`${running.origin}/api/v1/principal`, {
|
||||||
|
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
|
||||||
|
});
|
||||||
|
expect(((await both.json()) as { kind: string }).kind).toBe("authenticated");
|
||||||
|
|
||||||
|
// And that administrator can still open the display for testing, as a human.
|
||||||
|
const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, {
|
||||||
|
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
|
||||||
|
});
|
||||||
|
expect(descriptor.status).toBe(200);
|
||||||
|
expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe(
|
||||||
|
"authenticated",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
vi.mock("@ekklesia/shell/display-assets", () => ({
|
||||||
|
displayHostAssetUrls: Object.freeze({
|
||||||
|
html: "file:///surface/browser/display/index.html",
|
||||||
|
script: "file:///surface/browser/display/app.js",
|
||||||
|
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDisplayHostAssets,
|
||||||
|
DisplayHostAssetError,
|
||||||
|
loadDisplayHostAssets,
|
||||||
|
} from "../src/display-host-assets.js";
|
||||||
|
|
||||||
|
describe("display host asset loading", () => {
|
||||||
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
|
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
|
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||||
|
const script = "export const displayHost = true;\n";
|
||||||
|
const serviceWorker = "export const displayWorker = true;\n";
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(html)
|
||||||
|
.mockResolvedValueOnce(script)
|
||||||
|
.mockResolvedValueOnce(serviceWorker);
|
||||||
|
|
||||||
|
const assets = await loadDisplayHostAssets();
|
||||||
|
|
||||||
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
|
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||||
|
]);
|
||||||
|
expect(assets).toEqual({
|
||||||
|
html,
|
||||||
|
script,
|
||||||
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||||
|
readFileMock.mockRejectedValueOnce(
|
||||||
|
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await loadDisplayHostAssets().catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(failure).toBeInstanceOf(DisplayHostAssetError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The display host browser assets are unavailable.",
|
||||||
|
name: "DisplayHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DisplayHostAssetError: The display host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||||
|
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||||
|
expect(publicText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||||
|
for (const [html, script, serviceWorker] of [
|
||||||
|
["", "export {};", "export {};"],
|
||||||
|
["<html></html>", " \n", "export {};"],
|
||||||
|
["<html></html>", "export {};", " \n"],
|
||||||
|
] as const) {
|
||||||
|
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
|
||||||
|
DisplayHostAssetError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import { createHmac, randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
type IdentityNotificationFailure,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { SMTPServer } from "smtp-server";
|
||||||
|
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email verification and password recovery, end to end (ADR 0038).
|
||||||
|
*
|
||||||
|
* Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better
|
||||||
|
* Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The
|
||||||
|
* verification and reset links are **extracted from the delivered message** and then followed, which
|
||||||
|
* is the only way to prove the whole path rather than the pieces — a fake mail function would prove
|
||||||
|
* that a callback was invoked and nothing about whether the resulting link works.
|
||||||
|
*
|
||||||
|
* `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the
|
||||||
|
* internet and no real email is ever sent.
|
||||||
|
*
|
||||||
|
* Public signup is disabled in every scenario, matching production. Accounts that are not the
|
||||||
|
* bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses,
|
||||||
|
* which is deliberately not reachable over HTTP.
|
||||||
|
*/
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
const dashboardOrigin = "http://dashboard.localhost:5173";
|
||||||
|
const integrationSecret = "integration-test-secret-0123456789abcdefgh";
|
||||||
|
const initialPassword = "correct horse battery staple";
|
||||||
|
const replacementPassword = "an entirely different passphrase";
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const url = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof url !== "string" || url.trim() === "") {
|
||||||
|
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(name: string): string {
|
||||||
|
const url = new URL(baseDatabaseUrl());
|
||||||
|
url.pathname = `/${name}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<T extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<T>(text, [...values]);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
|
||||||
|
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModules,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModules, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freshInstallation(label: string): Promise<string> {
|
||||||
|
databaseCounter += 1;
|
||||||
|
const name =
|
||||||
|
`ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||||
|
const admin = await openAdministrativeClient();
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `create database "${name}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
const url = databaseUrl(name);
|
||||||
|
await applyCoreMigrations(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A delivered message, as the SMTP server actually received it. */
|
||||||
|
interface DeliveredMessage {
|
||||||
|
readonly to: readonly string[];
|
||||||
|
readonly data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailHarness {
|
||||||
|
readonly port: number;
|
||||||
|
readonly delivered: DeliveredMessage[];
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openResources: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const close of openResources.splice(0).reverse()) {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a free port before the API is configured.
|
||||||
|
*
|
||||||
|
* The identity base URL is what the emailed link is built from, so it has to be known *before*
|
||||||
|
* `startApi` binds. Without this the test could only assert the link's shape and would have to
|
||||||
|
* rewrite its host before following it — which is exactly the part worth proving. Reserving and
|
||||||
|
* releasing a port leaves a theoretical race, but nothing else on the loopback interface is
|
||||||
|
* competing for ephemeral ports inside this suite.
|
||||||
|
*/
|
||||||
|
async function reservePort(): Promise<number> {
|
||||||
|
const { createServer } = await import("node:net");
|
||||||
|
const server = createServer();
|
||||||
|
const port = await new Promise<number>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMailServer(): Promise<MailHarness> {
|
||||||
|
const delivered: DeliveredMessage[] = [];
|
||||||
|
const server = new SMTPServer({
|
||||||
|
authOptional: true,
|
||||||
|
disabledCommands: ["STARTTLS"],
|
||||||
|
onData(stream, session, callback) {
|
||||||
|
let data = "";
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
data += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
delivered.push({
|
||||||
|
data,
|
||||||
|
to: session.envelope.rcptTo.map((recipient) => recipient.address),
|
||||||
|
});
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
const address = server.server.address() as AddressInfo;
|
||||||
|
const harness: MailHarness = {
|
||||||
|
close: async () =>
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
}),
|
||||||
|
delivered,
|
||||||
|
port: address.port,
|
||||||
|
};
|
||||||
|
openResources.push(harness.close);
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly url: string;
|
||||||
|
readonly api: RunningApi;
|
||||||
|
readonly origin: string;
|
||||||
|
readonly mail: MailHarness | undefined;
|
||||||
|
readonly notificationFailures: IdentityNotificationFailure[];
|
||||||
|
readonly createIdentity: (input: {
|
||||||
|
readonly email: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly name: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A migrated installation with the API running, optionally with a working SMTP relay.
|
||||||
|
*
|
||||||
|
* `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the
|
||||||
|
* development environment; production configuration refuses both, which `identity-config` and
|
||||||
|
* `mail-contract` assert separately.
|
||||||
|
*/
|
||||||
|
async function installation(
|
||||||
|
label: string,
|
||||||
|
options: { readonly mail?: boolean } = {},
|
||||||
|
): Promise<Installation> {
|
||||||
|
const url = await freshInstallation(label);
|
||||||
|
const mail = options.mail === false ? undefined : await startMailServer();
|
||||||
|
const notificationFailures: IdentityNotificationFailure[] = [];
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
openResources.push(async () => {
|
||||||
|
await identity.close().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiPort = await reservePort();
|
||||||
|
const identityBaseUrl = `http://127.0.0.1:${apiPort}`;
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
publicSignupEnabled: false,
|
||||||
|
secret: integrationSecret,
|
||||||
|
trustedOrigins: [dashboardOrigin],
|
||||||
|
},
|
||||||
|
...(mail
|
||||||
|
? {
|
||||||
|
mail: {
|
||||||
|
fromAddress: "[email protected]",
|
||||||
|
fromName: "Ekklesia",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: String(mail.port),
|
||||||
|
security: "plaintext",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
port: apiPort,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
const api = await startApi(config, {
|
||||||
|
rateLimitOverride: { max: 1000, window: 1 },
|
||||||
|
reportNotificationFailure: (failure) => notificationFailures.push(failure),
|
||||||
|
});
|
||||||
|
openResources.push(async () => {
|
||||||
|
await api.shutdown().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = api.server.address();
|
||||||
|
if (!address || typeof address !== "object" || address.port !== apiPort) {
|
||||||
|
throw new IntegrationHarnessError("The API integration server did not bind the reserved port.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
mail,
|
||||||
|
notificationFailures,
|
||||||
|
origin: identityBaseUrl,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(
|
||||||
|
origin: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
headers: Readonly<Record<string, string>> = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
return await fetch(`${origin}${path}`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||||
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCookiePair(response: Response): string | undefined {
|
||||||
|
const setCookie = response.headers.get("set-cookie");
|
||||||
|
return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(
|
||||||
|
origin: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> {
|
||||||
|
const response = await post(origin, "/api/auth/sign-in/email", { email, password });
|
||||||
|
return { cookiePair: firstCookiePair(response), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function principalKind(origin: string, cookiePair: string | undefined): Promise<string> {
|
||||||
|
const response = await fetch(`${origin}/api/v1/principal`, {
|
||||||
|
headers: cookiePair ? { cookie: cookiePair } : {},
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as { readonly kind?: unknown };
|
||||||
|
return String(body.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a test reads a token, and only ever out of a message this suite's own SMTP server
|
||||||
|
* received. Nothing in the platform exposes one.
|
||||||
|
*
|
||||||
|
* Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by
|
||||||
|
* the encoder, and reassembling it here is what makes the extracted link the one a mail client would
|
||||||
|
* actually resolve.
|
||||||
|
*/
|
||||||
|
function extractLink(message: DeliveredMessage, contains: string): URL {
|
||||||
|
const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", "");
|
||||||
|
const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) =>
|
||||||
|
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||||
|
);
|
||||||
|
const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains));
|
||||||
|
if (!match) {
|
||||||
|
throw new IntegrationHarnessError(`No ${contains} link was delivered.`);
|
||||||
|
}
|
||||||
|
return new URL(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMessage(mail: MailHarness): DeliveredMessage {
|
||||||
|
const message = mail.delivered.at(-1);
|
||||||
|
if (!message) {
|
||||||
|
throw new IntegrationHarnessError("No message was delivered.");
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||||
|
client,
|
||||||
|
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||||
|
[email.toLowerCase()],
|
||||||
|
);
|
||||||
|
return rows[0]?.email_verified;
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account that has completed the real verification round trip.
|
||||||
|
*
|
||||||
|
* Recovery is only meaningful for a verified account, and building one through the actual flow —
|
||||||
|
* rather than by writing `email_verified` directly — keeps every recovery test standing on a
|
||||||
|
* verification path that genuinely works.
|
||||||
|
*/
|
||||||
|
async function verifiedAccountFor(install: Installation, email: string): Promise<void> {
|
||||||
|
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||||
|
await signIn(install.origin, email, initialPassword);
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||||
|
await fetch(link, { redirect: "manual" });
|
||||||
|
(install.mail as MailHarness).delivered.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
|
if (!admin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const name of trackedDatabases) {
|
||||||
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forges a verification token that is structurally valid but already expired.
|
||||||
|
*
|
||||||
|
* Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned
|
||||||
|
* release) — not a database row — so an expired one can be produced here from the same secret this
|
||||||
|
* suite configured, with no library internals and no waiting an hour. This is the only place any
|
||||||
|
* test constructs a token rather than reading one out of a delivered message.
|
||||||
|
*/
|
||||||
|
function forgeExpiredVerificationToken(email: string): string {
|
||||||
|
const encode = (value: object): string =>
|
||||||
|
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||||
|
const issued = Math.floor(Date.now() / 1000) - 7200;
|
||||||
|
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
exp: issued + 3600,
|
||||||
|
iat: issued,
|
||||||
|
})}`;
|
||||||
|
const signature = createHmac("sha256", integrationSecret)
|
||||||
|
.update(signingInput)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${signingInput}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("email verification, end to end", () => {
|
||||||
|
it("takes a new human account from unverified to signed in through a real emailed link", async () => {
|
||||||
|
const install = await installation("flow");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||||
|
|
||||||
|
// 1. Unverified. A correct password does not produce a session — the identity system refuses
|
||||||
|
// before creating one, so there is no half-authenticated state anywhere (ADR 0038).
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||||
|
const refused = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(refused.response.status).toBe(403);
|
||||||
|
expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||||
|
expect(refused.cookiePair).toBeUndefined();
|
||||||
|
expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous");
|
||||||
|
|
||||||
|
// 2. That attempt delivered a verification message through the real SMTP provider.
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
expect(mail.delivered).toHaveLength(1);
|
||||||
|
expect(lastMessage(mail).to).toEqual([email]);
|
||||||
|
expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address");
|
||||||
|
|
||||||
|
// 3. Follow the link exactly as it was delivered. Nothing rewrites it.
|
||||||
|
const link = extractLink(lastMessage(mail), "/verify-email");
|
||||||
|
expect(link.origin).toBe(install.origin);
|
||||||
|
const verified = await fetch(link, { redirect: "manual" });
|
||||||
|
|
||||||
|
// 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the
|
||||||
|
// API origin, and never to anything a request supplied.
|
||||||
|
expect(verified.status).toBe(302);
|
||||||
|
const landing = new URL(verified.headers.get("location") ?? "");
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email");
|
||||||
|
expect(landing.searchParams.get("error")).toBeNull();
|
||||||
|
// No session was minted by following an emailed link.
|
||||||
|
expect(verified.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||||
|
|
||||||
|
// 5. The same credentials now work, and the principal is a fully authenticated human.
|
||||||
|
const accepted = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(accepted.response.status).toBe(200);
|
||||||
|
expect(accepted.cookiePair).toBeDefined();
|
||||||
|
expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed token and an expired token, each with its own reason", async () => {
|
||||||
|
const install = await installation("bad-token");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Expired", password: initialPassword });
|
||||||
|
const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`);
|
||||||
|
|
||||||
|
for (const [token, reason] of [
|
||||||
|
["not-a-jwt", "INVALID_TOKEN"],
|
||||||
|
[forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"],
|
||||||
|
] as const) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`,
|
||||||
|
{ redirect: "manual" },
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const landing = new URL(response.headers.get("location") ?? "");
|
||||||
|
// The failure still lands on the host's own screen rather than a raw JSON error page.
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("error")).toBe(reason);
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when a link is followed twice, because the token is a signed claim", async () => {
|
||||||
|
const install = await installation("replay");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Replay", password: initialPassword });
|
||||||
|
await signIn(install.origin, email, initialPassword);
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||||
|
|
||||||
|
const first = await fetch(link, { redirect: "manual" });
|
||||||
|
const second = await fetch(link, { redirect: "manual" });
|
||||||
|
|
||||||
|
// Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed
|
||||||
|
// database row, so it is **not** single-use — it is valid until it expires. Replaying it is a
|
||||||
|
// no-op on an already-verified address and mints no session, which is what makes that safe.
|
||||||
|
// Mail scanners and prefetchers dereference links, so idempotence is a requirement here.
|
||||||
|
expect(first.status).toBe(302);
|
||||||
|
expect(second.status).toBe(302);
|
||||||
|
expect(second.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resends on request without revealing whether the address has an account", async () => {
|
||||||
|
const install = await installation("resend");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Resend", password: initialPassword });
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const known = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||||
|
const unknown = await post(install.origin, "/api/auth/send-verification-email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identical answers. The only difference anywhere is that a message was delivered, which the
|
||||||
|
// requester cannot observe.
|
||||||
|
expect(known.status).toBe(200);
|
||||||
|
expect(unknown.status).toBe(200);
|
||||||
|
expect(verifiedAlready.status).toBe(200);
|
||||||
|
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||||
|
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password recovery, end to end", () => {
|
||||||
|
it("replaces the password through a real emailed link and invalidates the old one", async () => {
|
||||||
|
const install = await installation("reset");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const requested = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
expect(requested.status).toBe(200);
|
||||||
|
expect(mail.delivered).toHaveLength(1);
|
||||||
|
expect(lastMessage(mail).to).toEqual([email]);
|
||||||
|
expect(lastMessage(mail).data).toContain("Reset your Ekklesia password");
|
||||||
|
|
||||||
|
// Follow the emailed link. Better Auth validates the token and hands the browser on to the
|
||||||
|
// Dashboard landing with it attached — which is where the host's reset form picks it up.
|
||||||
|
const link = extractLink(lastMessage(mail), "/reset-password/");
|
||||||
|
expect(link.origin).toBe(install.origin);
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
expect(handoff.status).toBe(302);
|
||||||
|
const landing = new URL(handoff.headers.get("location") ?? "");
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
const token = landing.searchParams.get("token") ?? "";
|
||||||
|
expect(token).not.toBe("");
|
||||||
|
|
||||||
|
const completed = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
expect(completed.status).toBe(200);
|
||||||
|
|
||||||
|
const oldCredential = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(oldCredential.response.status).toBe(401);
|
||||||
|
const newCredential = await signIn(install.origin, email, replacementPassword);
|
||||||
|
expect(newCredential.response.status).toBe(200);
|
||||||
|
expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes the reset token, so a captured link cannot be replayed", async () => {
|
||||||
|
const install = await installation("reset-replay");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const first = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
const second = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: "yet another different passphrase",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlike the verification token, this one is a database row Better Auth consumes.
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(second.status).toBe(400);
|
||||||
|
expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" });
|
||||||
|
expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs out every existing session for that account", async () => {
|
||||||
|
const install = await installation("reset-sessions");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const before = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated");
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of recovery is that it works when the credential is already compromised, so
|
||||||
|
// an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`).
|
||||||
|
expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the identity system's own password policy, with no reset-specific rule", async () => {
|
||||||
|
const install = await installation("reset-policy");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const tooShort = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: "short",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tooShort.status).toBe(400);
|
||||||
|
expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" });
|
||||||
|
// The old password still works: a rejected reset changes nothing.
|
||||||
|
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a request for an address with no account exactly as it answers a real one", async () => {
|
||||||
|
const install = await installation("enumeration");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const known = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const unknown = await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(known.status).toBe(unknown.status);
|
||||||
|
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||||
|
expect(JSON.stringify(await unknown.json())).not.toContain("stranger");
|
||||||
|
// Only the real account received anything.
|
||||||
|
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recovery when the installation has no outbound email", () => {
|
||||||
|
it("answers identically and records the operational failure out of band", async () => {
|
||||||
|
const install = await installation("no-mail", { mail: false });
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||||
|
|
||||||
|
const reset = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const resend = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||||
|
|
||||||
|
// The user-facing answer must not change: "we could not send it" for a real address and a
|
||||||
|
// generic success for an unknown one is an enumeration oracle that appears when SMTP breaks.
|
||||||
|
expect(reset.status).toBe(200);
|
||||||
|
expect(resend.status).toBe(200);
|
||||||
|
// The operator still learns about it, with a sanitized code and no address or token.
|
||||||
|
expect(install.notificationFailures).toEqual([
|
||||||
|
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(install.notificationFailures)).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still starts, serves the API, and refuses an unverified sign-in", async () => {
|
||||||
|
const install = await installation("no-mail-runs", { mail: false });
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||||
|
|
||||||
|
// Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification
|
||||||
|
// policy — which is exactly why the initial owner is created already verified.
|
||||||
|
expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200);
|
||||||
|
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recovery cannot be turned into a redirect or an account factory", () => {
|
||||||
|
it("refuses an attacker-supplied reset destination", async () => {
|
||||||
|
const install = await installation("redirect");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Redirect", password: initialPassword });
|
||||||
|
|
||||||
|
const external = await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email,
|
||||||
|
redirectTo: "https://evil.example.test/steal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external.status).toBe(403);
|
||||||
|
expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" });
|
||||||
|
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an attacker-supplied verification destination", async () => {
|
||||||
|
const install = await installation("redirect-verify");
|
||||||
|
|
||||||
|
for (const callbackURL of [
|
||||||
|
"https://evil.example.test",
|
||||||
|
"//evil.example.test",
|
||||||
|
"/\\evil.example.test",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
]) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||||
|
{ redirect: "manual" },
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.headers.get("location")).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => {
|
||||||
|
const install = await installation("redirect-ignored");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
|
||||||
|
// A *trusted* origin passes Better Auth's own check, so this is the case where the emailed
|
||||||
|
// link's destination would otherwise be caller-chosen. It is not: the message is built from
|
||||||
|
// configuration and the supplied value never reaches it.
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email,
|
||||||
|
redirectTo: `${dashboardOrigin}/somewhere-else`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const callback = new URL(link.searchParams.get("callbackURL") ?? "");
|
||||||
|
expect(callback.origin).toBe(dashboardOrigin);
|
||||||
|
expect(callback.pathname).toBe("/");
|
||||||
|
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
expect(link.href).not.toContain("somewhere-else");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps public signup disabled now that verification exists", async () => {
|
||||||
|
const install = await installation("signup");
|
||||||
|
|
||||||
|
const response = await post(install.origin, "/api/auth/sign-up/email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "Intruder",
|
||||||
|
password: initialPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email verification is not a licence to self-register. Future accounts arrive through
|
||||||
|
// deliberate administration, which this pass does not build.
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" });
|
||||||
|
expect(await emailVerifiedFlag(install.url, "[email protected]")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("managed devices are untouched by human email identity", () => {
|
||||||
|
it("leaves a paired device intact across a human password reset", async () => {
|
||||||
|
const install = await installation("devices");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
|
||||||
|
// A device is a row with a credential digest and no email, no password, and no verification
|
||||||
|
// state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove
|
||||||
|
// something this test is not about.
|
||||||
|
const client = new Client({ connectionString: install.url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
client,
|
||||||
|
'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)',
|
||||||
|
["Sanctuary display", "a".repeat(64)],
|
||||||
|
);
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>(
|
||||||
|
client,
|
||||||
|
'select "credential_hash", "revoked_at" from "ekklesia_core_device"',
|
||||||
|
);
|
||||||
|
expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]);
|
||||||
|
|
||||||
|
// The device table has no email or verification concept for a human flow to reach.
|
||||||
|
const columns = await safeQuery<{ column_name: string }>(
|
||||||
|
client,
|
||||||
|
"select column_name from information_schema.columns where table_name = 'ekklesia_core_device'",
|
||||||
|
);
|
||||||
|
const names = columns.map((column) => column.column_name).join(" ");
|
||||||
|
expect(names).not.toContain("email");
|
||||||
|
expect(names).not.toContain("verified");
|
||||||
|
expect(names).not.toContain("password");
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a device no mail, because a device has no address to send to", async () => {
|
||||||
|
const install = await installation("devices-mail");
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing about device identity participates in either flow.
|
||||||
|
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||||
|
expect(install.notificationFailures).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,373 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
type ApiHealthOptions,
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("health.status.read");
|
||||||
|
|
||||||
|
/** What a real driver failure can carry, and what must therefore never reach a response. */
|
||||||
|
const DRIVER_FAILURE_DETAIL =
|
||||||
|
"connect ECONNREFUSED postgresql://ekklesia:[email protected]:5432/ekklesia SELECT 1";
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthOptions(overrides: Partial<ApiHealthOptions> = {}) {
|
||||||
|
return {
|
||||||
|
checkDatabase: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||||
|
isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
health: ApiHealthOptions,
|
||||||
|
moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]),
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes,
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health,
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only
|
||||||
|
* timer functions these tests fake are `setTimeout` and `clearTimeout`. */
|
||||||
|
function drainMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */
|
||||||
|
async function settlesWithoutWaiting(promise: Response | Promise<Response>): Promise<boolean> {
|
||||||
|
let settled = false;
|
||||||
|
void Promise.resolve(promise).then(
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await drainMicrotasks();
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNotReady(response: Response): Promise<void> {
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ code: "NOT_READY" });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/health/live", () => {
|
||||||
|
it("returns 200 with a fixed body and Cache-Control: no-store", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_LIVE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("content-type")).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json()).toEqual({ status: "live" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||||
|
isModuleStartupComplete: vi.fn().mockReturnValue(false),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_LIVE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/health/ready", () => {
|
||||||
|
it("returns 200 with a fixed body when the database answers and module startup completed", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_READY_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ status: "ready" });
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_READY_PATH);
|
||||||
|
const text = await response.clone().text();
|
||||||
|
|
||||||
|
await expectNotReady(response);
|
||||||
|
for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) {
|
||||||
|
expect(text).not.toContain(fragment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a check that throws synchronously exactly like one that rejects", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(() => {
|
||||||
|
throw new Error(DRIVER_FAILURE_DETAIL);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 without touching the database before module startup completed", async () => {
|
||||||
|
const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) });
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 when shutdown began while the database query was in flight", async () => {
|
||||||
|
let startupComplete = true;
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(async () => {
|
||||||
|
startupComplete = false;
|
||||||
|
}),
|
||||||
|
isModuleStartupComplete: () => startupComplete,
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a fresh check for each sequential probe rather than caching a success", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await app.request(HEALTH_READY_PATH);
|
||||||
|
await app.request(HEALTH_READY_PATH);
|
||||||
|
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ready for a slow query that still answers inside the bound", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
let answer: () => void = () => undefined;
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const pending = app.request(HEALTH_READY_PATH);
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1);
|
||||||
|
answer();
|
||||||
|
|
||||||
|
expect((await pending).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readiness never piles up database checks", () => {
|
||||||
|
it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(() => new Promise<void>(() => undefined)),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
// Probes arriving inside the first check's window share it and its single deadline.
|
||||||
|
const first = app.request(HEALTH_READY_PATH);
|
||||||
|
const concurrent = app.request(HEALTH_READY_PATH);
|
||||||
|
await drainMicrotasks();
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||||
|
await expectNotReady(await first);
|
||||||
|
await expectNotReady(await concurrent);
|
||||||
|
|
||||||
|
// After the deadline the query is still unsettled: no new check starts, and each probe answers
|
||||||
|
// without waiting out another bound.
|
||||||
|
for (let probe = 0; probe < 20; probe += 1) {
|
||||||
|
const pending = app.request(HEALTH_READY_PATH);
|
||||||
|
expect(await settlesWithoutWaiting(pending)).toBe(true);
|
||||||
|
await expectNotReady(await pending);
|
||||||
|
}
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new check only once the timed-out query has actually settled", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
let settleHungQuery: (error: Error) => void = () => undefined;
|
||||||
|
const checkDatabase = vi
|
||||||
|
.fn<() => Promise<void>>()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((_resolve, reject) => {
|
||||||
|
settleHungQuery = reject;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const { app } = buildApp(healthOptions({ checkDatabase }));
|
||||||
|
|
||||||
|
const first = app.request(HEALTH_READY_PATH);
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||||
|
await expectNotReady(await first);
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
expect(checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The database cancels the abandoned statement; the next probe may finally ask again.
|
||||||
|
settleHungQuery(new Error(DRIVER_FAILURE_DETAIL));
|
||||||
|
await drainMicrotasks();
|
||||||
|
|
||||||
|
const recovered = await app.request(HEALTH_READY_PATH);
|
||||||
|
expect(recovered.status).toBe(200);
|
||||||
|
expect(checkDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health route boundaries", () => {
|
||||||
|
it.each([
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
const response = await app.request(path, { method });
|
||||||
|
expect(response.status, method).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
}
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => {
|
||||||
|
const { app, evaluator, runtime } = buildApp(healthOptions());
|
||||||
|
|
||||||
|
const response = await app.request(path, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot be shadowed by a module, even one whose id is "health"', async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const moduleRoutes = ["/live", "/ready"].map(
|
||||||
|
(path) =>
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "health",
|
||||||
|
path,
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
);
|
||||||
|
const { app } = buildApp(healthOptions(), moduleRoutes);
|
||||||
|
|
||||||
|
expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" });
|
||||||
|
expect(moduleHandler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The module's own routes still live where every module route lives, and only there.
|
||||||
|
const moduleResponse = await app.request("/api/v1/modules/health/ready");
|
||||||
|
expect(await moduleResponse.text()).toBe("module");
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { loadHostConfiguration } from "@ekklesia/core/host";
|
||||||
|
import { createMailProviderConfig } from "@ekklesia/core/mail";
|
||||||
|
import { createStorageProviderConfig } from "@ekklesia/core/storage";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { readConfigFromEnvironment } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
|
||||||
|
* previously could not: each executable read its own subset of the variables, and Doctor's subset
|
||||||
|
* omitted `S3_*` and the module secrets entirely.
|
||||||
|
*
|
||||||
|
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
|
||||||
|
* validates come from one `loadHostConfiguration` call and produce identical validated
|
||||||
|
* configuration, including every default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const secretKey = "sk-example-secret-material";
|
||||||
|
const smtpPassword = "sup3r-secret-relay-password";
|
||||||
|
|
||||||
|
function environment(
|
||||||
|
overrides: Readonly<Record<string, string | undefined>> = {},
|
||||||
|
): Readonly<Record<string, string | undefined>> {
|
||||||
|
return {
|
||||||
|
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
|
||||||
|
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
|
||||||
|
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
|
||||||
|
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
|
||||||
|
NODE_ENV: "production",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredInfrastructure = Object.freeze({
|
||||||
|
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||||
|
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||||
|
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||||
|
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
||||||
|
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
||||||
|
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
|
||||||
|
S3_ENDPOINT: "https://storage.example.org",
|
||||||
|
S3_SECRET_KEY: secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Exactly what Doctor validates, from exactly the same load the API performs. */
|
||||||
|
function doctorView(env: Readonly<Record<string, string | undefined>>) {
|
||||||
|
const host = loadHostConfiguration(env);
|
||||||
|
return {
|
||||||
|
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
|
||||||
|
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiView(env: Readonly<Record<string, string | undefined>>) {
|
||||||
|
const { environment: resolved, input } = readConfigFromEnvironment(env);
|
||||||
|
return createApiRuntimeConfig(input, { environment: resolved });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("host configuration parity between startApi and Doctor", () => {
|
||||||
|
it("derives identical object-storage configuration, defaults included", () => {
|
||||||
|
const env = environment(configuredInfrastructure);
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
||||||
|
// Named explicitly rather than only compared, so a change to either default fails here loudly.
|
||||||
|
expect(apiView(env).storage).toEqual({
|
||||||
|
accessKey: "AKIAEXAMPLEACCESSID",
|
||||||
|
bucketPrefix: "ekklesia",
|
||||||
|
deliveryEndpoint: "https://storage.example.org",
|
||||||
|
endpoint: "https://storage.example.org",
|
||||||
|
forcePathStyle: true,
|
||||||
|
region: "us-east-1",
|
||||||
|
secretKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives identical outbound-email configuration, defaults included", () => {
|
||||||
|
const env = environment(configuredInfrastructure);
|
||||||
|
|
||||||
|
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
||||||
|
expect(apiView(env).mail).toMatchObject({
|
||||||
|
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
|
||||||
|
// must fail in Doctor for the same reason it fails at runtime.
|
||||||
|
heloName: "mail.example.org",
|
||||||
|
host: "smtp.example.org",
|
||||||
|
port: 587,
|
||||||
|
security: "starttls",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees that an installation with neither service is complete", () => {
|
||||||
|
const env = environment();
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toBeUndefined();
|
||||||
|
expect(doctorView(env).storage).toBeUndefined();
|
||||||
|
expect(apiView(env).mail).toBeUndefined();
|
||||||
|
expect(doctorView(env).mail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries every storage field the direct-delivery pass established", () => {
|
||||||
|
const env = environment({
|
||||||
|
...configuredInfrastructure,
|
||||||
|
S3_BUCKET_PREFIX: "rogc",
|
||||||
|
S3_DELIVERY_ENDPOINT: "https://files.example.org",
|
||||||
|
S3_FORCE_PATH_STYLE: "false",
|
||||||
|
S3_REGION: "eu-west-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
||||||
|
expect(apiView(env).storage).toMatchObject({
|
||||||
|
bucketPrefix: "rogc",
|
||||||
|
deliveryEndpoint: "https://files.example.org",
|
||||||
|
forcePathStyle: false,
|
||||||
|
region: "eu-west-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the same malformed environment with the same sanitized code", () => {
|
||||||
|
for (const [overrides, code] of [
|
||||||
|
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
|
||||||
|
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
|
||||||
|
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
|
||||||
|
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
||||||
|
],
|
||||||
|
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
|
||||||
|
"MAIL_HELO_NAME_INVALID",
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
const env = environment(overrides);
|
||||||
|
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
||||||
|
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves module secrets from the same source the runtime is given", () => {
|
||||||
|
const env = environment({
|
||||||
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
|
||||||
|
).toBe("module-secret-material");
|
||||||
|
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
|
||||||
|
"module-secret-material",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the API the same modules directory Doctor and the operator commands read", () => {
|
||||||
|
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
|
||||||
|
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
|
||||||
|
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads only this listener's own address and port outside the shared loader", () => {
|
||||||
|
const config = apiView(
|
||||||
|
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.host).toBe("0.0.0.0");
|
||||||
|
expect(config.port).toBe(8080);
|
||||||
|
// Defaults, when unset, stay where they always were.
|
||||||
|
const defaults = apiView(environment());
|
||||||
|
expect(defaults.host).toBe("127.0.0.1");
|
||||||
|
expect(defaults.port).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
|
||||||
|
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
|
||||||
|
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
|
||||||
|
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
|
||||||
|
|
||||||
|
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
|
||||||
|
expect(() => apiView(env)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import {
|
||||||
|
createAuthorizationDrizzleDatabase,
|
||||||
|
createInitialPlatformOwner,
|
||||||
|
InitialPlatformOwnerError,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial platform-owner bootstrap, against a real PostgreSQL and the real HTTP transport
|
||||||
|
* (ADR 0035).
|
||||||
|
*
|
||||||
|
* The point of this suite is that it does **not** stop at asserting rows. A fresh installation is
|
||||||
|
* bootstrapped and then the resulting account is signed in through the exact browser-facing login
|
||||||
|
* transport Phase 7B Pass 1 established, its principal is read back, and it is used to reach a
|
||||||
|
* platform-owner-protected resource. Anything less would prove the command wrote a user, not that
|
||||||
|
* an operator can actually get into their dashboard — which is the only thing this pass is for.
|
||||||
|
*
|
||||||
|
* Every scenario runs with `publicSignupEnabled: false`, the production posture, so nothing here
|
||||||
|
* can accidentally depend on the signup route the real deployment does not have.
|
||||||
|
*/
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
||||||
|
const bootstrapCliPath = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"core",
|
||||||
|
"dist",
|
||||||
|
"cli",
|
||||||
|
"create-initial-owner.js",
|
||||||
|
);
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
const identityBaseUrl = "https://identity.example.test";
|
||||||
|
const dashboardOrigin = "https://dashboard.example.test";
|
||||||
|
const ownerPassword = "correct horse battery staple";
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const url = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof url !== "string" || url.trim() === "") {
|
||||||
|
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(name: string): string {
|
||||||
|
const url = new URL(baseDatabaseUrl());
|
||||||
|
url.pathname = `/${name}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDatabaseName(label: string): string {
|
||||||
|
databaseCounter += 1;
|
||||||
|
return `ekk_owner_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<T extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<T>(text, [...values]);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
|
||||||
|
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDisposableDatabase(label: string): Promise<string> {
|
||||||
|
const name = makeDatabaseName(label);
|
||||||
|
const admin = await openAdministrativeClient();
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `create database "${name}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
return databaseUrl(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-owner-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModules,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModules, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh, migrated installation with no identity and no owner — exactly what an operator has
|
||||||
|
* after `docker compose up` and `pnpm migrations:apply`. */
|
||||||
|
async function freshInstallation(label: string): Promise<string> {
|
||||||
|
const url = await createDisposableDatabase(label);
|
||||||
|
await applyCoreMigrations(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootstrapHandles {
|
||||||
|
readonly run: (
|
||||||
|
email: string,
|
||||||
|
password?: string,
|
||||||
|
name?: string,
|
||||||
|
) => ReturnType<typeof createInitialPlatformOwner>;
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives the same operation the CLI drives, with the same production composition. */
|
||||||
|
function bootstrapAgainst(url: string): BootstrapHandles {
|
||||||
|
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
authorization.close().catch(() => undefined),
|
||||||
|
identity.close().catch(() => undefined),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
run: (email, password = ownerPassword, name = "Platform Owner") =>
|
||||||
|
createInitialPlatformOwner(
|
||||||
|
{
|
||||||
|
authorizationDb: authorization.db,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
identityDb: identity.db,
|
||||||
|
},
|
||||||
|
{ email, name, password },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The production posture: public signup disabled, exactly as a real deployment must run — and, just
|
||||||
|
* as deliberately, **no outbound email configured at all**. A fresh installation has no relay yet,
|
||||||
|
* which is the whole reason the initial owner is created already verified (ADR 0038).
|
||||||
|
*/
|
||||||
|
function ownerApiConfig(url: string): ReturnType<typeof createApiRuntimeConfig> {
|
||||||
|
return createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
publicSignupEnabled: false,
|
||||||
|
secret: "integration-test-secret-0123456789abcdefgh",
|
||||||
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||||
|
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiOrigin(running: RunningApi): string {
|
||||||
|
const address = running.server.address();
|
||||||
|
if (!address || typeof address !== "object") {
|
||||||
|
throw new IntegrationHarnessError("The API integration server has no bound address.");
|
||||||
|
}
|
||||||
|
return `http://127.0.0.1:${address.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCookiePair(setCookieHeader: string): string {
|
||||||
|
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(
|
||||||
|
origin: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ cookiePair?: string; response: Response }> {
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const setCookie = response.headers.get("set-cookie");
|
||||||
|
return setCookie === null ? { response } : { cookiePair: firstCookiePair(setCookie), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
interface CliOutcome {
|
||||||
|
readonly code: number;
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the real compiled CLI as a real child process, with the password on stdin and never in
|
||||||
|
* `argv`. */
|
||||||
|
async function runBootstrapCli(
|
||||||
|
databaseUrl: string | undefined,
|
||||||
|
args: readonly string[],
|
||||||
|
password: string | undefined,
|
||||||
|
): Promise<CliOutcome> {
|
||||||
|
const environment: Record<string, string> = {
|
||||||
|
...(process.env as Record<string, string>),
|
||||||
|
NODE_ENV: "development",
|
||||||
|
};
|
||||||
|
if (databaseUrl === undefined) {
|
||||||
|
delete environment.DATABASE_URL;
|
||||||
|
} else {
|
||||||
|
environment.DATABASE_URL = databaseUrl;
|
||||||
|
}
|
||||||
|
const child = execFileAsync(process.execPath, [bootstrapCliPath, ...args], {
|
||||||
|
env: environment,
|
||||||
|
});
|
||||||
|
child.child.stdin?.end(password ?? "");
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await child;
|
||||||
|
return { code: 0, stderr, stdout };
|
||||||
|
} catch (error) {
|
||||||
|
const failure = error as { code?: number; stdout?: string; stderr?: string };
|
||||||
|
return {
|
||||||
|
code: typeof failure.code === "number" ? failure.code : 1,
|
||||||
|
stderr: failure.stderr ?? "",
|
||||||
|
stdout: failure.stdout ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countRows(url: string, table: string): Promise<number> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ count: string }>(
|
||||||
|
client,
|
||||||
|
`select count(*)::text from "${table}"`,
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.count ?? "0");
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Better Auth's own `emailVerified` column — the same value its `/verify-email` endpoint writes. */
|
||||||
|
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||||
|
client,
|
||||||
|
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
return rows[0]?.email_verified;
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
|
if (!admin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const name of trackedDatabases) {
|
||||||
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("initial platform-owner bootstrap", () => {
|
||||||
|
it("takes a fresh installation all the way to an authenticated owner in the browser", async () => {
|
||||||
|
const url = await freshInstallation("browserproof");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
let created: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
|
||||||
|
try {
|
||||||
|
created = await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
expect(created.identity).toBe("created");
|
||||||
|
expect(created.email).toBe(email.toLowerCase());
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
|
||||||
|
// 1. The exact browser-facing login transport, on a deployment where signup does not exist.
|
||||||
|
const session = await signIn(origin, email, ownerPassword);
|
||||||
|
expect(session.response.status).toBe(200);
|
||||||
|
expect(session.cookiePair).toBeDefined();
|
||||||
|
const cookie = session.cookiePair as string;
|
||||||
|
|
||||||
|
// 2. The principal the browser bootstrap reads.
|
||||||
|
const principalResponse = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
|
||||||
|
expect(principalResponse.status).toBe(200);
|
||||||
|
const principal = (await principalResponse.json()) as {
|
||||||
|
kind: string;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
expect(principal.kind).toBe("authenticated");
|
||||||
|
expect(principal.userId).toBe(created.userId);
|
||||||
|
|
||||||
|
// 3. A platform-owner-protected resource actually opens for them. This is the assertion that
|
||||||
|
// separates "a row was written" from "the operator can run their platform".
|
||||||
|
const grants = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`, {
|
||||||
|
headers: { cookie },
|
||||||
|
});
|
||||||
|
expect(grants.status).toBe(200);
|
||||||
|
|
||||||
|
// 4. And the same resource is still closed to everyone else.
|
||||||
|
const anonymous = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`);
|
||||||
|
expect(anonymous.status).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves public signup disabled — the owner exists without a signup route existing", async () => {
|
||||||
|
const url = await freshInstallation("nosignup");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
const attempted = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `intruder-${randomBytes(4).toString("hex")}@example.test`,
|
||||||
|
name: "Intruder",
|
||||||
|
password: ownerPassword,
|
||||||
|
}),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bootstrap command created an account; it did not open a door for anyone else to.
|
||||||
|
expect(attempted.status).toBe(400);
|
||||||
|
expect(await attempted.text()).toContain("EMAIL_PASSWORD_SIGN_UP_DISABLED");
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
|
||||||
|
// And the owner can still sign in, so disabling signup did not disable the account.
|
||||||
|
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the guard", () => {
|
||||||
|
it("refuses a second bootstrap, changes no password, and creates no second owner", async () => {
|
||||||
|
const url = await freshInstallation("guard");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(email);
|
||||||
|
|
||||||
|
// Same address, different password: must not become a password reset.
|
||||||
|
await expect(bootstrap.run(email, "an entirely different password")).rejects.toMatchObject({
|
||||||
|
code: "INITIAL_OWNER_ALREADY_COMPLETED",
|
||||||
|
});
|
||||||
|
// A different address must not become a second owner.
|
||||||
|
await expect(
|
||||||
|
bootstrap.run(`second-${randomBytes(4).toString("hex")}@example.test`),
|
||||||
|
).rejects.toMatchObject({ code: "INITIAL_OWNER_ALREADY_COMPLETED" });
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
// The refused second attempt with a new address created no stray identity: the sentinel is
|
||||||
|
// checked before anything is created, not after.
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await signIn(origin, email, "an entirely different password")).response.status,
|
||||||
|
).not.toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validation reuses the identity system's own policy", () => {
|
||||||
|
it("rejects an invalid email, and a password outside the 8–128 bound", async () => {
|
||||||
|
const url = await freshInstallation("validation");
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await expect(bootstrap.run("not-an-email-address")).rejects.toMatchObject({
|
||||||
|
code: "INITIAL_OWNER_EMAIL_INVALID",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
bootstrap.run(`short-${randomBytes(4).toString("hex")}@example.test`, "1234567"),
|
||||||
|
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_SHORT" });
|
||||||
|
await expect(
|
||||||
|
bootstrap.run(`long-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(129)),
|
||||||
|
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_LONG" });
|
||||||
|
|
||||||
|
// Exactly the boundary the browser accepts, so the two can never diverge.
|
||||||
|
await expect(
|
||||||
|
bootstrap.run(`edge-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(128)),
|
||||||
|
).resolves.toMatchObject({ identity: "created" });
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every rejection happened before anything was written, and the one success is the only row.
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never puts the password into an error", async () => {
|
||||||
|
const url = await freshInstallation("nosecrets");
|
||||||
|
const secret = "sup3rSecretBootstrapPassword!";
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await bootstrap.run("not-an-email-address", secret);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(InitialPlatformOwnerError);
|
||||||
|
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||||||
|
expect(rendered).not.toContain(secret);
|
||||||
|
expect(rendered).not.toContain("postgres");
|
||||||
|
expect(rendered).not.toContain("select");
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("an identity that already exists", () => {
|
||||||
|
it("adopts a usable account and leaves its password untouched", async () => {
|
||||||
|
const url = await freshInstallation("adopt");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
// Exactly the partial failure this guards: the identity committed, the grant never ran.
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await createBootstrapIdentityCreator(identity.db)({
|
||||||
|
email,
|
||||||
|
name: "Half Finished",
|
||||||
|
password: ownerPassword,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await identity.close();
|
||||||
|
}
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
const result = await bootstrap.run(email, "a different password than before");
|
||||||
|
expect(result.identity).toBe("adopted");
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
// Adoption granted ownership. It did not take the account over: the original password still
|
||||||
|
// works and the one supplied to the rerun does not.
|
||||||
|
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await signIn(origin, email, "a different password than before")).response.status,
|
||||||
|
).not.toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a credential-less row rather than consuming bootstrap on an unusable owner", async () => {
|
||||||
|
const url = await freshInstallation("unusable");
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
client,
|
||||||
|
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
values ($1, $2, $3, false, now(), now())`,
|
||||||
|
["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Shell Row", "[email protected]"],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await expect(bootstrap.run("[email protected]")).rejects.toMatchObject({
|
||||||
|
code: "INITIAL_OWNER_IDENTITY_UNUSABLE",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decisive part: bootstrap is still available. Granting ownership to a row nobody can
|
||||||
|
// authenticate as would have consumed the single use and left the installation unusable with
|
||||||
|
// no second chance.
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partial failure", () => {
|
||||||
|
it("reports a failed grant honestly and stays recoverable by rerunning", async () => {
|
||||||
|
const url = await freshInstallation("partial");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
createInitialPlatformOwner(
|
||||||
|
{
|
||||||
|
authorizationDb: authorization.db,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
grantOwnership: () => {
|
||||||
|
throw new Error("the grant failed after the identity committed");
|
||||||
|
},
|
||||||
|
identityDb: identity.db,
|
||||||
|
},
|
||||||
|
{ email, name: "Owner", password: ownerPassword },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" });
|
||||||
|
} finally {
|
||||||
|
await Promise.all([authorization.close(), identity.close()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The identity committed; the grant did not; the single-use sentinel was never consumed.
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
|
||||||
|
|
||||||
|
// Rerunning the identical command finishes the job, which is the whole recovery story.
|
||||||
|
const retry = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await expect(retry.run(email)).resolves.toMatchObject({ identity: "adopted" });
|
||||||
|
} finally {
|
||||||
|
await retry.close();
|
||||||
|
}
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("concurrency", () => {
|
||||||
|
it("gives exactly one owner when several bootstraps race", async () => {
|
||||||
|
const url = await freshInstallation("race");
|
||||||
|
const attempts = 5;
|
||||||
|
const handles = Array.from({ length: attempts }, () => bootstrapAgainst(url));
|
||||||
|
try {
|
||||||
|
const outcomes = await Promise.allSettled(
|
||||||
|
handles.map((handle, index) =>
|
||||||
|
handle.run(`racer-${index}-${randomBytes(3).toString("hex")}@example.test`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled");
|
||||||
|
expect(fulfilled).toHaveLength(1);
|
||||||
|
for (const outcome of outcomes.filter((candidate) => candidate.status === "rejected")) {
|
||||||
|
expect((outcome as PromiseRejectedResult).reason).toMatchObject({
|
||||||
|
code: "INITIAL_OWNER_ALREADY_COMPLETED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await Promise.all(handles.map((handle) => handle.close()));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(1);
|
||||||
|
// The losers created nothing at all. Serializing whole invocations — not just the grant — is
|
||||||
|
// what keeps a race from leaving signable-in accounts that own nothing behind.
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the command itself", () => {
|
||||||
|
it("succeeds, prints something useful, and leaks nothing", async () => {
|
||||||
|
const url = await freshInstallation("cli");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const result = await runBootstrapCli(url, ["--email", email], ownerPassword);
|
||||||
|
|
||||||
|
expect(result.code).toBe(0);
|
||||||
|
expect(result.stdout).toContain("Initial platform owner created successfully.");
|
||||||
|
expect(result.stdout).toContain(email.toLowerCase());
|
||||||
|
const everything = `${result.stdout} ${result.stderr}`;
|
||||||
|
expect(everything).not.toContain(ownerPassword);
|
||||||
|
expect(everything).not.toContain("postgresql://");
|
||||||
|
expect(everything).not.toContain("password");
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
// The command's real product: an operator who can now sign in.
|
||||||
|
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a password supplied on the command line", async () => {
|
||||||
|
const url = await freshInstallation("argv");
|
||||||
|
const result = await runBootstrapCli(
|
||||||
|
url,
|
||||||
|
["--email", "[email protected]", "--password", "hunter2hunter2"],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
expect(result.stderr).toContain("--password is not supported");
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails clearly without a database, and refuses to migrate one itself", async () => {
|
||||||
|
const missing = await runBootstrapCli(
|
||||||
|
undefined,
|
||||||
|
["--email", "[email protected]"],
|
||||||
|
"whatever",
|
||||||
|
);
|
||||||
|
expect(missing.code).toBe(1);
|
||||||
|
expect(missing.stderr).toContain("DATABASE_URL is required.");
|
||||||
|
|
||||||
|
// A database that exists but was never migrated is a deployment step that has not happened,
|
||||||
|
// and this command says so rather than quietly running migrations for a production operator.
|
||||||
|
const unmigrated = await createDisposableDatabase("unmigrated");
|
||||||
|
const result = await runBootstrapCli(
|
||||||
|
unmigrated,
|
||||||
|
["--email", "[email protected]"],
|
||||||
|
ownerPassword,
|
||||||
|
);
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
expect(result.stderr).toContain("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: unmigrated });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const tables = await safeQuery<{ count: string }>(
|
||||||
|
client,
|
||||||
|
`select count(*)::text from information_schema.tables where table_schema = 'public'`,
|
||||||
|
);
|
||||||
|
expect(Number(tables[0]?.count ?? "0")).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second run at the command level too", async () => {
|
||||||
|
const url = await freshInstallation("clirerun");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
expect((await runBootstrapCli(url, ["--email", email], ownerPassword)).code).toBe(0);
|
||||||
|
|
||||||
|
const second = await runBootstrapCli(url, ["--email", email], "a different password");
|
||||||
|
expect(second.code).toBe(1);
|
||||||
|
expect(second.stderr).toContain("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||||
|
expect(second.stderr).not.toContain("a different password");
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("initial platform-owner bootstrap and email verification", () => {
|
||||||
|
/**
|
||||||
|
* The trusted-bootstrap verification exception (ADR 0038).
|
||||||
|
*
|
||||||
|
* A verified address became a precondition for holding a session in Phase 7C Pass 2. The initial
|
||||||
|
* owner is created on a freshly provisioned server, by an operator holding the database
|
||||||
|
* credentials, at a moment when SMTP may not exist — so requiring an email round trip here would
|
||||||
|
* produce a working installation nobody can ever sign in to. These scenarios exist to prove that
|
||||||
|
* the exception is real, that it is narrow, and that mail remains genuinely absent throughout.
|
||||||
|
*/
|
||||||
|
it("creates the owner already verified, proven in the database and in the browser", async () => {
|
||||||
|
const url = await freshInstallation("verifiedowner");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database state: the exact column Better Auth's own verification endpoint writes, so the row
|
||||||
|
// is indistinguishable from one that verified normally.
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(true);
|
||||||
|
|
||||||
|
// ...and the browser-facing consequence, which is the only thing that actually matters.
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
const session = await signIn(origin, email, ownerPassword);
|
||||||
|
expect(session.response.status).toBe(200);
|
||||||
|
expect(session.cookiePair).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs no outbound email configured, at bootstrap or at sign-in", async () => {
|
||||||
|
const url = await freshInstallation("nomailowner");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
// No SMTP host, no relay, no network: `createBootstrapIdentityCreator` constructs its own
|
||||||
|
// Better Auth instance with no `emailVerification` configuration at all.
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
// The API this suite starts configures no mail either, so nothing in this scenario could have
|
||||||
|
// sent or received a message.
|
||||||
|
expect(ownerApiConfig(url).mail).toBeUndefined();
|
||||||
|
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an adopted identity verified too, so bootstrap never yields an owner who cannot sign in", async () => {
|
||||||
|
const url = await freshInstallation("adoptverified");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
// An identity that exists before bootstrap runs — created here through the same trusted creator,
|
||||||
|
// which always writes `emailVerified: false`.
|
||||||
|
const first = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await first.run(email);
|
||||||
|
} finally {
|
||||||
|
await first.close();
|
||||||
|
}
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(client, 'update "ekklesia_core_user" set "email_verified" = false');
|
||||||
|
await safeQuery(client, 'delete from "ekklesia_core_platform_owner_bootstrap"');
|
||||||
|
await safeQuery(client, 'delete from "ekklesia_core_platform_owner"');
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(false);
|
||||||
|
|
||||||
|
const second = bootstrapAgainst(url);
|
||||||
|
let adopted: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
|
||||||
|
try {
|
||||||
|
adopted = await second.run(email);
|
||||||
|
} finally {
|
||||||
|
await second.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(adopted.identity).toBe("adopted");
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies only the owner, leaving every other account subject to the normal policy", async () => {
|
||||||
|
const url = await freshInstallation("narrowexception");
|
||||||
|
const ownerEmail = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const otherEmail = `member-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(ownerEmail);
|
||||||
|
// A second identity created through the identical trusted creator, but never bootstrapped.
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await createBootstrapIdentityCreator(identity.db)({
|
||||||
|
email: otherEmail,
|
||||||
|
name: "Ordinary Member",
|
||||||
|
password: ownerPassword,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await identity.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await emailVerifiedFlag(url, ownerEmail)).toBe(true);
|
||||||
|
expect(await emailVerifiedFlag(url, otherEmail)).toBe(false);
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
expect((await signIn(origin, ownerEmail, ownerPassword)).response.status).toBe(200);
|
||||||
|
// The exception did not widen: an ordinary account still has to verify, and on this
|
||||||
|
// mail-less installation it simply cannot sign in yet.
|
||||||
|
const refused = await signIn(origin, otherEmail, ownerPassword);
|
||||||
|
expect(refused.response.status).toBe(403);
|
||||||
|
expect(await refused.response.json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||||
|
expect(refused.cookiePair).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,804 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
type ModuleConfigurationDeclarationInput,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationStore,
|
||||||
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleConfigurationRecord,
|
||||||
|
type ModuleConfigurationStore,
|
||||||
|
ModuleConfigurationStoreError,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type ModuleDatabase,
|
||||||
|
resolveModuleEligibility,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
|
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||||
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
import {
|
||||||
|
createFakeModuleSecretSource,
|
||||||
|
createInMemoryModuleConfigurationStore,
|
||||||
|
} from "./support/in-memory-module-configuration.js";
|
||||||
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555");
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configuration shape the real Announcements product would need as an Ekklesia module: a slide
|
||||||
|
* duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied
|
||||||
|
* storage credential.
|
||||||
|
*
|
||||||
|
* Modelled from the reference repository, never imported from it. Nothing Announcements-specific
|
||||||
|
* exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive
|
||||||
|
* the whole path — manifest, validation, storage, API, snapshot — without the module reaching any
|
||||||
|
* platform infrastructure.
|
||||||
|
*/
|
||||||
|
const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [
|
||||||
|
{
|
||||||
|
key: "slideSeconds",
|
||||||
|
type: "integer",
|
||||||
|
default: 7,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 600,
|
||||||
|
description: "How long each slide is shown.",
|
||||||
|
},
|
||||||
|
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
|
||||||
|
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
|
||||||
|
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
|
||||||
|
{ key: "storageCredential", type: "secret", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly id: string;
|
||||||
|
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${fixture.id}`,
|
||||||
|
type: "module",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
configuration: fixture.configuration ?? [],
|
||||||
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
|
id: fixture.id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: `${fixture.id} module`,
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallOptions {
|
||||||
|
readonly fixtures?: readonly ModuleFixture[];
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
readonly platformOwner?: boolean;
|
||||||
|
readonly grants?: readonly string[];
|
||||||
|
readonly records?: readonly ModuleConfigurationRecord[];
|
||||||
|
readonly secrets?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
||||||
|
readonly store?: ModuleConfigurationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly app: Hono;
|
||||||
|
readonly modules: readonly DiscoveredModule[];
|
||||||
|
readonly store: ModuleConfigurationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
||||||
|
* composition: read the stored settings, resolve eligibility and configuration in one pass, then
|
||||||
|
* build the UI registry and every option from that same resolution. Doing it the same way here is
|
||||||
|
* what makes these assertions evidence about production composition rather than a bespoke wiring.
|
||||||
|
*/
|
||||||
|
async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
for (const fixture of options.fixtures ?? [
|
||||||
|
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||||
|
]) {
|
||||||
|
await createModule(modulesDirectory, fixture);
|
||||||
|
}
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []);
|
||||||
|
const secrets = createFakeModuleSecretSource(options.secrets ?? {});
|
||||||
|
const lifecycleStore = createInMemoryModuleLifecycleStore();
|
||||||
|
const bootSettings = await store.readSettings();
|
||||||
|
const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), {
|
||||||
|
secrets,
|
||||||
|
settings: bootSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const held = new Set(options.grants ?? []);
|
||||||
|
const evaluator = createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
|
||||||
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const authorizationStore = {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
|
store: authorizationStore,
|
||||||
|
},
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
moduleConfiguration: {
|
||||||
|
bootSettings,
|
||||||
|
discoveredModules: modules,
|
||||||
|
secrets,
|
||||||
|
snapshots: eligibility.configurations,
|
||||||
|
store,
|
||||||
|
},
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: {
|
||||||
|
discoveredModules: modules,
|
||||||
|
eligibility: eligibility.entries,
|
||||||
|
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
||||||
|
},
|
||||||
|
store: lifecycleStore,
|
||||||
|
},
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: Object.freeze({
|
||||||
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
|
}) as LegacyModuleRuntimeServices,
|
||||||
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, modules, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
function owner(): InstallOptions {
|
||||||
|
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigurationBody {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly keys: readonly {
|
||||||
|
readonly key: string;
|
||||||
|
readonly type: string;
|
||||||
|
readonly required: boolean;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly default?: string | number | boolean;
|
||||||
|
readonly values?: readonly string[];
|
||||||
|
readonly value?: string;
|
||||||
|
readonly configured: boolean;
|
||||||
|
}[];
|
||||||
|
readonly unknownKeys: readonly string[];
|
||||||
|
readonly restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readConfiguration(
|
||||||
|
app: Hono,
|
||||||
|
moduleId = "announcements",
|
||||||
|
): Promise<ConfigurationBody> {
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
return (await response.json()) as ConfigurationBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function putConfiguration(
|
||||||
|
app: Hono,
|
||||||
|
values: unknown,
|
||||||
|
moduleId = "announcements",
|
||||||
|
origin = trustedOrigin,
|
||||||
|
) {
|
||||||
|
return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
|
||||||
|
body: JSON.stringify({ values }),
|
||||||
|
headers: { "content-type": "application/json", origin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /api/v1/module-configuration/:moduleId", () => {
|
||||||
|
it("returns the module's declared schema with defaults and stored values", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await readConfiguration(app);
|
||||||
|
|
||||||
|
expect(body.moduleId).toBe("announcements");
|
||||||
|
expect(body.keys.map((entry) => entry.key)).toEqual([
|
||||||
|
"slideSeconds",
|
||||||
|
"pollIntervalSeconds",
|
||||||
|
"bucket",
|
||||||
|
"fit",
|
||||||
|
"storageCredential",
|
||||||
|
]);
|
||||||
|
expect(body.keys[0]).toEqual({
|
||||||
|
configured: false,
|
||||||
|
default: 7,
|
||||||
|
description: "How long each slide is shown.",
|
||||||
|
key: "slideSeconds",
|
||||||
|
required: false,
|
||||||
|
type: "integer",
|
||||||
|
});
|
||||||
|
expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({
|
||||||
|
configured: true,
|
||||||
|
required: true,
|
||||||
|
value: "slides",
|
||||||
|
});
|
||||||
|
expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a secret value, only whether the deployment supplies one", async () => {
|
||||||
|
const configured = await install({
|
||||||
|
...owner(),
|
||||||
|
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||||
|
});
|
||||||
|
const body = await readConfiguration(configured.app);
|
||||||
|
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||||
|
|
||||||
|
expect(secretEntry).toEqual({
|
||||||
|
configured: true,
|
||||||
|
key: "storageCredential",
|
||||||
|
required: true,
|
||||||
|
type: "secret",
|
||||||
|
});
|
||||||
|
// Not redacted, not null, not a placeholder — the field is absent entirely, and the secret
|
||||||
|
// string appears nowhere in the payload.
|
||||||
|
expect(secretEntry).not.toHaveProperty("value");
|
||||||
|
expect(secretEntry).not.toHaveProperty("default");
|
||||||
|
expect(JSON.stringify(body)).not.toContain("s3cret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unsupplied secret as not configured, still without a value field", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const body = await readConfiguration(app);
|
||||||
|
|
||||||
|
expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({
|
||||||
|
configured: false,
|
||||||
|
key: "storageCredential",
|
||||||
|
required: true,
|
||||||
|
type: "secret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists stored keys the current manifest no longer declares, without failing", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "removedInThisVersion", moduleId: "announcements", value: "kept" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await readConfiguration(app);
|
||||||
|
|
||||||
|
expect(body.unknownKeys).toEqual(["removedInThisVersion"]);
|
||||||
|
expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a module declaring no configuration as an empty, valid document", async () => {
|
||||||
|
const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] });
|
||||||
|
const body = await readConfiguration(app, "calendar");
|
||||||
|
|
||||||
|
expect(body.keys).toEqual([]);
|
||||||
|
expect(body.unknownKeys).toEqual([]);
|
||||||
|
expect(body.restartRequired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a module this process did not discover", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not cached and varies on origin and cookie", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("Vary")).toContain("Cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => {
|
||||||
|
const failing: ModuleConfigurationStore = {
|
||||||
|
readSettings: async () => {
|
||||||
|
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED");
|
||||||
|
},
|
||||||
|
replace: async () => {
|
||||||
|
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let bootRead = false;
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
store: {
|
||||||
|
readSettings: async () => {
|
||||||
|
if (!bootRead) {
|
||||||
|
bootRead = true;
|
||||||
|
return { records: [] };
|
||||||
|
}
|
||||||
|
return failing.readSettings();
|
||||||
|
},
|
||||||
|
replace: (input) => failing.replace(input),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/v1/module-configuration/:moduleId", () => {
|
||||||
|
it("stores values and reports that a restart is required", async () => {
|
||||||
|
const { app, store } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = (await response.json()) as ConfigurationBody;
|
||||||
|
expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({
|
||||||
|
configured: true,
|
||||||
|
value: "12",
|
||||||
|
});
|
||||||
|
// The running process still holds the boot snapshot; claiming otherwise would be a lie.
|
||||||
|
expect(body.restartRequired).toBe(true);
|
||||||
|
expect((await store.readSettings()).records).toEqual([
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no pending restart when the submitted document matches what booted", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, { bucket: "slides" });
|
||||||
|
|
||||||
|
expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the whole document, so an omitted key is cleared", async () => {
|
||||||
|
const { app, store } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await putConfiguration(app, { bucket: "slides" });
|
||||||
|
|
||||||
|
expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches another module's stored values", async () => {
|
||||||
|
const { app, store } = await install({
|
||||||
|
...owner(),
|
||||||
|
fixtures: [
|
||||||
|
{ configuration: announcementsConfiguration, id: "announcements" },
|
||||||
|
{ configuration: [{ key: "bucket", type: "string" }], id: "calendar" },
|
||||||
|
],
|
||||||
|
records: [
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await putConfiguration(app, { bucket: "renamed" });
|
||||||
|
|
||||||
|
expect((await store.readSettings()).records).toEqual([
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "renamed" },
|
||||||
|
{ key: "bucket", moduleId: "calendar", value: "events" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to accept secret material over HTTP", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, {
|
||||||
|
bucket: "slides",
|
||||||
|
storageCredential: "s3cret",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" });
|
||||||
|
// Nothing was written at all — not the secret, and not the valid key beside it.
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a key the module does not declare", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" });
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a string for an integer", { bucket: "slides", slideSeconds: "12" }],
|
||||||
|
["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }],
|
||||||
|
["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }],
|
||||||
|
["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }],
|
||||||
|
["a value outside a declared enum", { bucket: "slides", fit: "stretch" }],
|
||||||
|
["a string shorter than its declared minLength", { bucket: "" }],
|
||||||
|
["a nested object", { bucket: { name: "slides" } }],
|
||||||
|
["an array", { bucket: ["slides"] }],
|
||||||
|
["null", { bucket: null }],
|
||||||
|
])("rejects %s without storing anything", async (_label, values) => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await putConfiguration(app, values);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a body with an unexpected top-level key", { values: {}, force: true }],
|
||||||
|
["a body with no values object", {}],
|
||||||
|
["a bare array body", []],
|
||||||
|
["a bare string body", "bucket=slides"],
|
||||||
|
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"__proto__",
|
||||||
|
"constructor",
|
||||||
|
"prototype",
|
||||||
|
])("rejects the reserved property name %s as a submitted key", async (key) => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
body: `{"values":{${JSON.stringify(key)}:"polluted"}}`,
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
// Nothing about the request reached `Object.prototype`.
|
||||||
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
// `bucket` is required and absent: an operator may save partial work.
|
||||||
|
const response = await putConfiguration(app, { slideSeconds: 12 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((await store.readSettings()).records).toEqual([
|
||||||
|
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await putConfiguration(
|
||||||
|
app,
|
||||||
|
{ bucket: "slides" },
|
||||||
|
"announcements",
|
||||||
|
untrustedOrigin,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect((await store.readSettings()).records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing origin", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
body: JSON.stringify({ values: { bucket: "slides" } }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const oversized = JSON.stringify({
|
||||||
|
values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) },
|
||||||
|
});
|
||||||
|
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
body: oversized,
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module configuration authority", () => {
|
||||||
|
it("refuses an anonymous caller on both verbs", async () => {
|
||||||
|
const { app } = await install();
|
||||||
|
|
||||||
|
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||||
|
expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an ordinary authenticated user who holds no configuration permission", async () => {
|
||||||
|
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||||
|
|
||||||
|
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits a non-owner holding exactly core.modules.configuration.manage", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let the lifecycle permission configure a module, or the reverse", async () => {
|
||||||
|
// The two authorities are deliberately separate: tuning how a module behaves and deciding
|
||||||
|
// whether it runs at all are different decisions (ADR 0029).
|
||||||
|
const lifecycleOnly = await install({
|
||||||
|
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||||
|
).toBe(403);
|
||||||
|
expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
||||||
|
|
||||||
|
const configurationOnly = await install({
|
||||||
|
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configuration state is visible through the lifecycle resource", () => {
|
||||||
|
it("reports a module blocked by missing required configuration, naming the key not the value", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
secrets: { announcements: { storageCredential: "s3cret" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
readonly modules: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly runtime: {
|
||||||
|
readonly state: string;
|
||||||
|
readonly reason?: string;
|
||||||
|
readonly configurationKey?: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.modules[0]?.runtime).toEqual({
|
||||||
|
configurationKey: "bucket",
|
||||||
|
reason: "configuration_missing",
|
||||||
|
state: "blocked",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a module blocked by a missing secret, without naming the secret's value", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
const raw = await response.text();
|
||||||
|
|
||||||
|
expect(raw).toContain("secret_missing");
|
||||||
|
expect(raw).toContain("storageCredential");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a configuration-blocked module out of the mountable UI inventory", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
fixtures: [
|
||||||
|
{ configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const inventory = (await (await app.request("/api/v1/module-ui")).json()) as {
|
||||||
|
readonly modules: readonly { readonly id: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is
|
||||||
|
// not eligible, so it is not published.
|
||||||
|
expect(inventory.modules).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no secret or configuration value leaks through any surface", () => {
|
||||||
|
it("keeps secret material out of every response body this resource produces", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
secrets: { announcements: { storageCredential: "super-secret-value" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = await (
|
||||||
|
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
})
|
||||||
|
).text();
|
||||||
|
const mutated = await (await putConfiguration(app, { bucket: "slides" })).text();
|
||||||
|
const lifecycle = await (
|
||||||
|
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
||||||
|
).text();
|
||||||
|
|
||||||
|
for (const payload of [listed, mutated, lifecycle]) {
|
||||||
|
expect(payload).not.toContain("super-secret-value");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not serve a secret value even if a row for that key somehow exists in storage", async () => {
|
||||||
|
// Unreachable through the API, which refuses to write a secret key, and unreachable through the
|
||||||
|
// resolver, which never reads one. This plants the row directly to prove the *serialization*
|
||||||
|
// boundary holds independently: a secret's `value` is omitted by construction, not filtered by a
|
||||||
|
// branch someone could remove.
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [
|
||||||
|
{ key: "bucket", moduleId: "announcements", value: "slides" },
|
||||||
|
{ key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await readConfiguration(app);
|
||||||
|
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
|
||||||
|
|
||||||
|
expect(secretEntry).not.toHaveProperty("value");
|
||||||
|
expect(secretEntry?.configured).toBe(false);
|
||||||
|
expect(JSON.stringify(body)).not.toContain("planted-in-the-database");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never leaks a filesystem path, package name, or manifest path", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
...owner(),
|
||||||
|
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const listed = await (
|
||||||
|
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
})
|
||||||
|
).text();
|
||||||
|
|
||||||
|
expect(listed).not.toContain("/tmp");
|
||||||
|
expect(listed).not.toContain("@test/module-");
|
||||||
|
expect(listed).not.toContain("ekklesia.module.json");
|
||||||
|
expect(listed).not.toContain("dist/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the configuration prefix is a sibling of the module route namespace", () => {
|
||||||
|
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
|
||||||
|
// `config` and `configuration` are ordinary lowercase static segments under
|
||||||
|
// `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on
|
||||||
|
// `/api/v1/modules/announcements/config`. A sibling prefix cannot collide.
|
||||||
|
expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
||||||
|
expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration");
|
||||||
|
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const collision = await app.request("/api/v1/modules/announcements/config", {
|
||||||
|
body: JSON.stringify({ values: {} }),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(collision.status).toBe(404);
|
||||||
|
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
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",
|
||||||
|
"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 activeConnectionCount(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,568 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
|
import {
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
DISPLAY_SERVICE_WORKER_PATH,
|
||||||
|
ModuleDisplayConfigurationError,
|
||||||
|
moduleDisplayDescriptorUrl,
|
||||||
|
moduleDisplayPageUrl,
|
||||||
|
} from "../src/module-display-routes.js";
|
||||||
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||||
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const displayPermission = "announcements.display.view";
|
||||||
|
|
||||||
|
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||||
|
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
||||||
|
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
||||||
|
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly display?: boolean;
|
||||||
|
readonly id?: string;
|
||||||
|
readonly ui?: boolean;
|
||||||
|
readonly version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
|
||||||
|
const id = fixture.id ?? "announcements";
|
||||||
|
const version = fixture.version ?? "1.4.2";
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${id}`,
|
||||||
|
type: "module",
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
|
...(fixture.display
|
||||||
|
? {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: `${id} module`,
|
||||||
|
permissions: fixture.display
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: `View the ${id} display.`,
|
||||||
|
id: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
|
||||||
|
);
|
||||||
|
if (fixture.ui) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fixture.display) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallOptions extends ModuleFixture {
|
||||||
|
readonly assets?: DisplayHostAssets;
|
||||||
|
readonly evaluator?: AuthorizationEvaluator;
|
||||||
|
readonly granted?: readonly string[];
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
/** The API builds publication from the eligible/running set. `false` models a module that was
|
||||||
|
* discovered but disabled or dependency-blocked before runtime startup. */
|
||||||
|
readonly published?: boolean;
|
||||||
|
readonly withAssets?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly app: Hono;
|
||||||
|
readonly modules: readonly DiscoveredModule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeServices(): LegacyModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeAuthorizationStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(options: InstallOptions): Promise<Installation> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
await createModule(modulesDirectory, options);
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
const granted = new Set(options.granted ?? []);
|
||||||
|
const evaluator =
|
||||||
|
options.evaluator ??
|
||||||
|
createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
||||||
|
platformOwners: { isPlatformOwner: async () => false },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const publishedModules = options.published === false ? [] : modules;
|
||||||
|
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
|
store: fakeAuthorizationStore(),
|
||||||
|
},
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||||
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||||
|
: {}),
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeServices(),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
moduleUi,
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, modules };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(): EkklesiaPrincipal {
|
||||||
|
return createAuthenticatedPrincipal(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module display routes — authorization and descriptor", () => {
|
||||||
|
it("gives an unpaired viewer the inert shell but never the descriptor", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const [page, descriptor] = await Promise.all([
|
||||||
|
app.request(moduleDisplayPageUrl("announcements")),
|
||||||
|
app.request(moduleDisplayDescriptorUrl("announcements")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The page is deliberately open: a television nobody has paired has no identity to present, and
|
||||||
|
// a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing —
|
||||||
|
// the same module-agnostic bytes every display gets, with no module id and no data.
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(await page.text()).toBe(displayHostAssets.html);
|
||||||
|
|
||||||
|
// Authorization is entirely in the descriptor, and it has not moved.
|
||||||
|
expect(descriptor.status).toBe(403);
|
||||||
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
||||||
|
const { app } = await install({ display: true, principal: signedIn() });
|
||||||
|
|
||||||
|
// Being signed in is not being permitted, and the descriptor is where that is decided.
|
||||||
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
version: "1.4.2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await app.request(moduleDisplayPageUrl("announcements"));
|
||||||
|
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||||
|
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
expect(await page.text()).toBe(displayHostAssets.html);
|
||||||
|
expect(await descriptor.json()).toEqual({
|
||||||
|
viewer: { kind: "authenticated", userId: viewerId },
|
||||||
|
module: {
|
||||||
|
contributions: [],
|
||||||
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
|
display: {
|
||||||
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
|
},
|
||||||
|
id: "announcements",
|
||||||
|
page: { url: moduleDisplayPageUrl("announcements") },
|
||||||
|
permissions: [displayPermission],
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||||
|
version: "1.4.2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(descriptor.headers.get("content-type")).toContain("application/json");
|
||||||
|
expect(descriptor.headers.get("vary")).toContain("Origin");
|
||||||
|
expect(descriptor.headers.get("vary")).toContain("Cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
|
||||||
|
const authorize = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ allowed: true })
|
||||||
|
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
|
||||||
|
const evaluator: AuthorizationEvaluator = { authorize };
|
||||||
|
const { app } = await install({ display: true, evaluator, principal: signedIn() });
|
||||||
|
|
||||||
|
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(authorize).toHaveBeenCalledTimes(2);
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("vary")).toContain("Origin");
|
||||||
|
expect(response.headers.get("vary")).toContain("Cookie");
|
||||||
|
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
|
||||||
|
expect(body).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the outer page stable when the internal artifact version changes", () => {
|
||||||
|
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
|
||||||
|
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module display routes — role and lifecycle separation", () => {
|
||||||
|
it("keeps a display-only module out of the primary UI inventory", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect(await inventory.json()).toEqual({ modules: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not give a dashboard-only module a display route", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn(), ui: true });
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
|
||||||
|
modules: [{ id: "announcements" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes both roles independently when a module declares both", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
ui: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
|
modules: Array<{ display: { url: string } | null }>;
|
||||||
|
}
|
||||||
|
).modules,
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
published: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||||
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers only the exact stable page and descriptor paths", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const route of [
|
||||||
|
"/display",
|
||||||
|
"/display/announcements/",
|
||||||
|
"/display/announcements/settings",
|
||||||
|
"/api/v1/module-displays",
|
||||||
|
"/api/v1/module-displays/announcements/",
|
||||||
|
"/api/v1/module-displays/announcements/settings",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(route)).status, route).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails composition when a display exists but its host assets were not supplied", async () => {
|
||||||
|
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
|
||||||
|
ModuleDisplayConfigurationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module display host asset publication", () => {
|
||||||
|
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.script);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
expect(await notModified.text()).toBe("");
|
||||||
|
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
|
||||||
|
expect(notModified.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
|
||||||
|
for (const header of [
|
||||||
|
`W/${displayHostAssets.scriptEtag}`,
|
||||||
|
"*",
|
||||||
|
`"stale", ${displayHostAssets.scriptEtag}`,
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": header },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
header,
|
||||||
|
).toBe(304);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": '"stale"' },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
|
||||||
|
"/assets/ekklesia-display/index.html",
|
||||||
|
"/assets/ekklesia-display/secret.txt",
|
||||||
|
"/assets/ekklesia-display/../secret.txt",
|
||||||
|
"/api/v1/module-ui/announcements/1.4.2/management",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses identical non-interpolated HTML for every eligible module", async () => {
|
||||||
|
const first = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
const second = await install({
|
||||||
|
display: true,
|
||||||
|
granted: ["signage.display.view"],
|
||||||
|
id: "signage",
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
|
||||||
|
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
|
||||||
|
expect(firstHtml).toBe(secondHtml);
|
||||||
|
expect(firstHtml).not.toContain("announcements");
|
||||||
|
expect(secondHtml).not.toContain("signage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
||||||
|
const source = createDisplayHostAssets(
|
||||||
|
"<html>display host</html>",
|
||||||
|
"export const host = 1;\n",
|
||||||
|
"export const worker = 1;\n",
|
||||||
|
);
|
||||||
|
const mutable = { ...source };
|
||||||
|
const { app } = await install({ assets: mutable, display: true });
|
||||||
|
mutable.html = "<html>mutated secret</html>";
|
||||||
|
mutable.script = "throw new Error('mutated secret');";
|
||||||
|
|
||||||
|
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
install({
|
||||||
|
assets: { ...source, scriptEtag: '"stale-validator"' },
|
||||||
|
display: true,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the generic host script when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the offline service worker from the path whose directory is its scope", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
|
||||||
|
|
||||||
|
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
|
||||||
|
// else. The Dashboard is never controlled by it (ADR 0043).
|
||||||
|
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
|
||||||
|
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates the service worker with its own validator, never the host script's", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
|
||||||
|
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(wrongValidator.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the service worker when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be reached through a neighbouring or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
|
||||||
|
"/display/sw.js/",
|
||||||
|
"/sw.js",
|
||||||
|
"/assets/ekklesia-display/sw.js",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,582 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationStore,
|
||||||
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleLifecycleStore,
|
||||||
|
ModuleLifecycleStoreError,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
resolveModuleEligibility,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly id: string;
|
||||||
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${fixture.id}`,
|
||||||
|
type: "module",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
|
id: fixture.id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: `${fixture.id} module`,
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFixtures: readonly ModuleFixture[] = [
|
||||||
|
{ id: "storage" },
|
||||||
|
{ id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface InstallOptions {
|
||||||
|
readonly fixtures?: readonly ModuleFixture[];
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
readonly platformOwner?: boolean;
|
||||||
|
readonly bootDisabled?: readonly string[];
|
||||||
|
readonly store?: ModuleLifecycleStore;
|
||||||
|
/** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator
|
||||||
|
* — never a bypass, so a delegated holder is exercised the same way a real one would be. */
|
||||||
|
readonly grants?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly app: Hono;
|
||||||
|
readonly modules: readonly DiscoveredModule[];
|
||||||
|
readonly store: ModuleLifecycleStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
||||||
|
* composition: resolve eligibility from the stored configuration, then build the UI publication
|
||||||
|
* registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it
|
||||||
|
* the same way here is what makes these assertions evidence about production composition rather than
|
||||||
|
* about a bespoke test wiring.
|
||||||
|
*/
|
||||||
|
async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
for (const fixture of options.fixtures ?? defaultFixtures) {
|
||||||
|
await createModule(modulesDirectory, fixture);
|
||||||
|
}
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const store =
|
||||||
|
options.store ??
|
||||||
|
createInMemoryModuleLifecycleStore(
|
||||||
|
(options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })),
|
||||||
|
);
|
||||||
|
const bootConfiguration = await store.readConfiguration();
|
||||||
|
const eligibility = resolveModuleEligibility(modules, bootConfiguration);
|
||||||
|
|
||||||
|
const held = new Set(options.grants ?? []);
|
||||||
|
const evaluator = createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
|
||||||
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const authorizationStore = {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
// Permission composition still runs over the whole discovered inventory, unchanged: a
|
||||||
|
// disabled module's permissions stay registered and grantable, and nothing enforces them
|
||||||
|
// because it mounts no routes.
|
||||||
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
|
store: authorizationStore,
|
||||||
|
},
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: {
|
||||||
|
discoveredModules: modules,
|
||||||
|
eligibility: eligibility.entries,
|
||||||
|
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
},
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: Object.freeze({
|
||||||
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
|
}) as LegacyModuleRuntimeServices,
|
||||||
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, modules, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
function owner(): InstallOptions {
|
||||||
|
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LifecycleEntry {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
|
||||||
|
readonly runtime: {
|
||||||
|
readonly state: string;
|
||||||
|
readonly reason?: string;
|
||||||
|
readonly dependency?: { readonly kind: string; readonly moduleId: string };
|
||||||
|
};
|
||||||
|
readonly restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLifecycle(app: Hono): Promise<readonly LifecycleEntry[]> {
|
||||||
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] };
|
||||||
|
return body.modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) {
|
||||||
|
return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, {
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
headers: { "content-type": "application/json", origin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /api/v1/module-lifecycle", () => {
|
||||||
|
it("reports every discovered module with both axes separated", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
|
||||||
|
expect(await readLifecycle(app)).toEqual([
|
||||||
|
{
|
||||||
|
configured: { enabled: true, explicit: false },
|
||||||
|
id: "storage",
|
||||||
|
restartRequired: false,
|
||||||
|
runtime: { state: "running" },
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
configured: { enabled: true, explicit: false },
|
||||||
|
id: "announcements",
|
||||||
|
restartRequired: false,
|
||||||
|
runtime: { state: "running" },
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains a disabled module and the dependent it blocks, with a structured reason", async () => {
|
||||||
|
const { app } = await install({ ...owner(), bootDisabled: ["storage"] });
|
||||||
|
|
||||||
|
expect(await readLifecycle(app)).toEqual([
|
||||||
|
{
|
||||||
|
configured: { enabled: false, explicit: true },
|
||||||
|
id: "storage",
|
||||||
|
restartRequired: false,
|
||||||
|
runtime: { state: "disabled" },
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
configured: { enabled: true, explicit: false },
|
||||||
|
id: "announcements",
|
||||||
|
restartRequired: false,
|
||||||
|
runtime: {
|
||||||
|
dependency: { kind: "module", moduleId: "storage" },
|
||||||
|
reason: "required_dependency_disabled",
|
||||||
|
state: "blocked",
|
||||||
|
},
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not cached and varies on origin and cookie", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("Vary")).toContain("Cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an anonymous caller", async () => {
|
||||||
|
const { app } = await install();
|
||||||
|
const response = await app.request(MODULE_LIFECYCLE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => {
|
||||||
|
// The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this
|
||||||
|
// resource is deliberately restricted, because it is the only place a disabled module is
|
||||||
|
// visible at all.
|
||||||
|
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
const lifecycle = await app.request(MODULE_LIFECYCLE_PATH);
|
||||||
|
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect(lifecycle.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
||||||
|
// Holding it confers no module authority: the Dashboard's own manage permission is untouched by
|
||||||
|
// this grant, so a lifecycle administrator cannot reorganize navigation.
|
||||||
|
const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
||||||
|
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
expect(reorganize.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => {
|
||||||
|
const failing: ModuleLifecycleStore = {
|
||||||
|
readConfiguration: async () => {
|
||||||
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED");
|
||||||
|
},
|
||||||
|
setEnabled: async () => {
|
||||||
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Boot with a working store, then swap in the failing one for the request path only.
|
||||||
|
const { app } = await install({ ...owner(), store: failingAfterBoot(failing) });
|
||||||
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Answers the one boot-time read with an empty configuration, then fails every later call — so a
|
||||||
|
* request-path failure can be exercised without making startup composition itself impossible. */
|
||||||
|
function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore {
|
||||||
|
let bootRead = false;
|
||||||
|
return {
|
||||||
|
async readConfiguration() {
|
||||||
|
if (!bootRead) {
|
||||||
|
bootRead = true;
|
||||||
|
return { modules: [] };
|
||||||
|
}
|
||||||
|
return failing.readConfiguration();
|
||||||
|
},
|
||||||
|
setEnabled: (input) => failing.setEnabled(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => {
|
||||||
|
it("persists a decision and reports honestly that the running process has not changed", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
|
||||||
|
const response = await setEnabled(app, "announcements", false);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
configured: { enabled: false, explicit: true },
|
||||||
|
id: "announcements",
|
||||||
|
// Not `disabled`: this process is still serving that module. Claiming otherwise would be a
|
||||||
|
// lie about a module that is still running.
|
||||||
|
restartRequired: true,
|
||||||
|
runtime: { state: "running" },
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the dependents of a newly disabled module as restart-pending too", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
|
||||||
|
await setEnabled(app, "storage", false);
|
||||||
|
|
||||||
|
expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([
|
||||||
|
["storage", true],
|
||||||
|
["announcements", true],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent, and re-enabling clears the pending restart", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
|
||||||
|
await setEnabled(app, "announcements", false);
|
||||||
|
await setEnabled(app, "announcements", false);
|
||||||
|
const reEnabled = await setEnabled(app, "announcements", true);
|
||||||
|
|
||||||
|
expect(await reEnabled.json()).toEqual({
|
||||||
|
configured: { enabled: true, explicit: true },
|
||||||
|
id: "announcements",
|
||||||
|
restartRequired: false,
|
||||||
|
runtime: { state: "running" },
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await setEnabled(app, "not-installed", false);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
||||||
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed module identifier without reaching the store", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await setEnabled(app, "Not%20A%20Module", false);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
|
||||||
|
const response = await setEnabled(app, "announcements", false, untrustedOrigin);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing origin", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||||
|
body: JSON.stringify({ enabled: false }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an authenticated caller without the restricted permission", async () => {
|
||||||
|
const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
||||||
|
|
||||||
|
const response = await setEnabled(app, "announcements", false);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a non-boolean value", { enabled: "false" }],
|
||||||
|
["a missing field", {}],
|
||||||
|
["an unexpected key alongside a valid one", { enabled: false, force: true }],
|
||||||
|
["an array", []],
|
||||||
|
["a bare string", "disabled"],
|
||||||
|
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
||||||
|
const { app, store } = await install(owner());
|
||||||
|
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) });
|
||||||
|
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
||||||
|
body: oversized,
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never leaks a filesystem path, package name, or manifest path through either verb", async () => {
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const listed = await (
|
||||||
|
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
||||||
|
).text();
|
||||||
|
const mutated = await (await setEnabled(app, "announcements", false)).text();
|
||||||
|
|
||||||
|
for (const payload of [listed, mutated]) {
|
||||||
|
expect(payload).not.toContain("/tmp");
|
||||||
|
expect(payload).not.toContain("@test/module-");
|
||||||
|
expect(payload).not.toContain("ekklesia.module.json");
|
||||||
|
expect(payload).not.toContain("dist/");
|
||||||
|
expect(payload).not.toContain("entrypoint");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lifecycle state governs module UI exposure", () => {
|
||||||
|
it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
bootDisabled: ["announcements"],
|
||||||
|
platformOwner: true,
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
|
readonly modules: readonly { readonly id: string }[];
|
||||||
|
};
|
||||||
|
const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as {
|
||||||
|
readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[];
|
||||||
|
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Discovered, validated, and declaring a real compiled UI artifact — and still absent, because
|
||||||
|
// publication is built from the eligible set rather than the discovered one.
|
||||||
|
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]);
|
||||||
|
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]);
|
||||||
|
expect(navigation.groups).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
bootDisabled: ["storage"],
|
||||||
|
platformOwner: true,
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
|
readonly modules: readonly { readonly id: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(inventory.modules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not remove a module from navigation merely because a restart is pending", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
platformOwner: true,
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
|
||||||
|
await setEnabled(app, "announcements", false);
|
||||||
|
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
|
readonly modules: readonly { readonly id: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// The module really is still mounted and still serving; hiding its UI now would break a viewer
|
||||||
|
// mid-session for a change that has not taken effect. `restartRequired` is where that pending
|
||||||
|
// change is reported.
|
||||||
|
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the lifecycle prefix is a sibling of the module route namespace", () => {
|
||||||
|
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
|
||||||
|
// `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named
|
||||||
|
// `announcements` may legitimately register `PUT /enabled` — mounted at exactly
|
||||||
|
// `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation
|
||||||
|
// there, registration order would decide which one answers.
|
||||||
|
expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
||||||
|
expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle");
|
||||||
|
|
||||||
|
const { app } = await install(owner());
|
||||||
|
const collision = await app.request("/api/v1/modules/announcements/enabled", {
|
||||||
|
body: JSON.stringify({ enabled: false }),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(collision.status).toBe(404);
|
||||||
|
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type ModuleRealtimePublisher,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type PermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRealtimeRegistry,
|
||||||
|
type ModuleRealtimeSubscription,
|
||||||
|
startDiscoveredModuleRuntime,
|
||||||
|
type StartedModuleRuntime,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { type ServerType, serve } from "@hono/node-server";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { mountModuleRealtimeRoutes } from "../src/module-realtime-routes.js";
|
||||||
|
|
||||||
|
const allowedUserId = parseEkklesiaUserId("99999999-9999-4999-8999-999999999999");
|
||||||
|
const deniedUserId = parseEkklesiaUserId("77777777-7777-4777-8777-777777777777");
|
||||||
|
const stableDeliveryUrl =
|
||||||
|
"https://objects.example.test/announcements/slides/welcome.png?X-Amz-Signature=stable";
|
||||||
|
const TRANSPORT_DEADLINE_MS = 2_000;
|
||||||
|
|
||||||
|
async function withinTransportDeadline<Value>(
|
||||||
|
operation: Promise<Value>,
|
||||||
|
message: string,
|
||||||
|
onTimeout: () => void = () => undefined,
|
||||||
|
): Promise<Value> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
onTimeout();
|
||||||
|
reject(new Error(message));
|
||||||
|
}, TRANSPORT_DEADLINE_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTransportCondition(predicate: () => boolean, message: string): Promise<void> {
|
||||||
|
let cancelled = false;
|
||||||
|
await withinTransportDeadline(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
const inspect = (): void => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (predicate()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(inspect, 5);
|
||||||
|
};
|
||||||
|
inspect();
|
||||||
|
}),
|
||||||
|
message,
|
||||||
|
() => {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixtureControl {
|
||||||
|
readonly events: string[];
|
||||||
|
publisher?: ModuleRealtimePublisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveEvent {
|
||||||
|
readonly event: "snapshot" | "update";
|
||||||
|
readonly payload: unknown;
|
||||||
|
readonly sequence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveSseConnection {
|
||||||
|
readonly #abort: AbortController;
|
||||||
|
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
#buffer = "";
|
||||||
|
|
||||||
|
constructor(response: Response, abort: AbortController) {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Realtime integration response had no body.");
|
||||||
|
}
|
||||||
|
this.#abort = abort;
|
||||||
|
this.#reader = response.body.getReader();
|
||||||
|
}
|
||||||
|
|
||||||
|
async readEvent(): Promise<LiveEvent> {
|
||||||
|
for (;;) {
|
||||||
|
const boundary = this.#buffer.indexOf("\n\n");
|
||||||
|
if (boundary >= 0) {
|
||||||
|
const block = this.#buffer.slice(0, boundary);
|
||||||
|
this.#buffer = this.#buffer.slice(boundary + 2);
|
||||||
|
if (block.startsWith(":")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const event = block
|
||||||
|
.split("\n")
|
||||||
|
.find((line) => line.startsWith("event: "))
|
||||||
|
?.slice("event: ".length);
|
||||||
|
const data = block
|
||||||
|
.split("\n")
|
||||||
|
.find((line) => line.startsWith("data: "))
|
||||||
|
?.slice("data: ".length);
|
||||||
|
if ((event !== "snapshot" && event !== "update") || data === undefined) {
|
||||||
|
throw new Error("Realtime integration received an invalid event.");
|
||||||
|
}
|
||||||
|
const envelope = JSON.parse(data) as { payload?: unknown; sequence?: unknown };
|
||||||
|
if (!Number.isSafeInteger(envelope.sequence) || (envelope.sequence as number) <= 0) {
|
||||||
|
throw new Error("Realtime integration received an invalid sequence.");
|
||||||
|
}
|
||||||
|
return { event, payload: envelope.payload, sequence: envelope.sequence as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.#readWithinDeadline();
|
||||||
|
if (result.done) {
|
||||||
|
throw new Error("Realtime integration stream closed before the expected event.");
|
||||||
|
}
|
||||||
|
this.#buffer += new TextDecoder().decode(result.value, { stream: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectClosed(): Promise<void> {
|
||||||
|
const result = await this.#readWithinDeadline();
|
||||||
|
expect(result.done).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.#abort.abort();
|
||||||
|
await withinTransportDeadline(
|
||||||
|
// Abort and cancel race by design; a rejection means one of them already released the
|
||||||
|
// transport. A deadline still propagates and fails the test.
|
||||||
|
this.#reader.cancel().catch(() => undefined),
|
||||||
|
"Realtime integration client cancellation timed out.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #readWithinDeadline() {
|
||||||
|
return withinTransportDeadline(
|
||||||
|
this.#reader.read(),
|
||||||
|
"Realtime integration read timed out.",
|
||||||
|
() => this.#abort.abort(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFixtureModule(control: FixtureControl): Promise<{
|
||||||
|
readonly directory: string;
|
||||||
|
readonly symbol: symbol;
|
||||||
|
}> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-native-realtime-"));
|
||||||
|
const moduleDirectory = path.join(directory, "announcements");
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
const symbol = Symbol.for(`ekklesia.native-realtime.${Date.now()}.${Math.random()}`);
|
||||||
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = control;
|
||||||
|
const expression = `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: "@test/module-announcements",
|
||||||
|
type: "module",
|
||||||
|
version: "0.1.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
|
id: "announcements",
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: "Announcements fixture",
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: "announcements.slideshow.view",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
version: "0.1.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default {
|
||||||
|
id: "announcements",
|
||||||
|
version: "0.1.0",
|
||||||
|
register({ realtime }) {
|
||||||
|
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||||
|
${expression}.events.push("register");
|
||||||
|
},
|
||||||
|
start({ realtime }) {
|
||||||
|
${expression}.publisher = realtime;
|
||||||
|
realtime.publish("state", {
|
||||||
|
mode: "on",
|
||||||
|
currentSlide: 0,
|
||||||
|
previousSlide: -1,
|
||||||
|
slides: [${JSON.stringify(stableDeliveryUrl)}]
|
||||||
|
});
|
||||||
|
${expression}.events.push("start");
|
||||||
|
},
|
||||||
|
stop() { ${expression}.events.push("stop"); }
|
||||||
|
};\n`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return { directory, symbol };
|
||||||
|
}
|
||||||
|
|
||||||
|
function services(): LegacyModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: async (headers) => {
|
||||||
|
const cookie = headers.get("cookie");
|
||||||
|
if (cookie === "ekklesia-session=allowed") {
|
||||||
|
return createAuthenticatedPrincipal(allowedUserId);
|
||||||
|
}
|
||||||
|
if (cookie === "ekklesia-session=denied") {
|
||||||
|
return createAuthenticatedPrincipal(deniedUserId);
|
||||||
|
}
|
||||||
|
return ANONYMOUS_PRINCIPAL;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorization(): AuthorizationEvaluator {
|
||||||
|
return createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async ({ userId }) => userId === allowedUserId },
|
||||||
|
platformOwners: { isPlatformOwner: async () => false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindLiveServer(
|
||||||
|
runtime: StartedModuleRuntime,
|
||||||
|
registry: PermissionRegistry,
|
||||||
|
): Promise<{
|
||||||
|
readonly activeSubscriptions: () => number;
|
||||||
|
readonly origin: string;
|
||||||
|
readonly server: ServerType;
|
||||||
|
}> {
|
||||||
|
const app = new Hono();
|
||||||
|
let activeSubscriptions = 0;
|
||||||
|
const observedRealtime: ModuleRealtimeRegistry = {
|
||||||
|
channels: runtime.realtime.channels,
|
||||||
|
closeSubscriptions: () => runtime.realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (moduleId) => runtime.realtime.publisherFor(moduleId),
|
||||||
|
subscribe: (moduleId, channel): ModuleRealtimeSubscription => {
|
||||||
|
const subscription = runtime.realtime.subscribe(moduleId, channel);
|
||||||
|
activeSubscriptions += 1;
|
||||||
|
let closed = false;
|
||||||
|
return Object.freeze({
|
||||||
|
close: (): void => {
|
||||||
|
if (!closed) {
|
||||||
|
closed = true;
|
||||||
|
activeSubscriptions -= 1;
|
||||||
|
}
|
||||||
|
subscription.close();
|
||||||
|
},
|
||||||
|
read: () => subscription.read(),
|
||||||
|
snapshot: subscription.snapshot,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mountModuleRealtimeRoutes(app, {
|
||||||
|
evaluator: authorization(),
|
||||||
|
realtime: observedRealtime,
|
||||||
|
registry,
|
||||||
|
runtime: identityRuntime(),
|
||||||
|
});
|
||||||
|
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
|
||||||
|
|
||||||
|
let pendingServer: ServerType | undefined;
|
||||||
|
const server = await withinTransportDeadline(
|
||||||
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
|
pendingServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, () =>
|
||||||
|
resolve(pendingServer as ServerType),
|
||||||
|
);
|
||||||
|
pendingServer.once("error", reject);
|
||||||
|
}),
|
||||||
|
"Realtime integration server bind timed out.",
|
||||||
|
() => pendingServer?.close(),
|
||||||
|
);
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Realtime integration server did not bind a TCP port.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeSubscriptions: () => activeSubscriptions,
|
||||||
|
origin: `http://127.0.0.1:${address.port}`,
|
||||||
|
server,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(
|
||||||
|
origin: string,
|
||||||
|
cookie: string,
|
||||||
|
): Promise<{
|
||||||
|
readonly connection: LiveSseConnection;
|
||||||
|
readonly response: Response;
|
||||||
|
}> {
|
||||||
|
const abort = new AbortController();
|
||||||
|
const response = await withinTransportDeadline(
|
||||||
|
fetch(`${origin}/api/v1/module-realtime/announcements/state`, {
|
||||||
|
headers: { cookie },
|
||||||
|
signal: abort.signal,
|
||||||
|
}),
|
||||||
|
"Realtime integration connection timed out.",
|
||||||
|
() => abort.abort(),
|
||||||
|
);
|
||||||
|
return { connection: new LiveSseConnection(response, abort), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeServer(server: ServerType): Promise<void> {
|
||||||
|
await withinTransportDeadline(
|
||||||
|
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||||
|
"Realtime integration server close timed out.",
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
server as ServerType & {
|
||||||
|
closeAllConnections?: () => void;
|
||||||
|
}
|
||||||
|
).closeAllConnections?.(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<{ readonly body: unknown; readonly response: Response }> {
|
||||||
|
const abort = new AbortController();
|
||||||
|
const operation = (async (): Promise<{ readonly body: unknown; readonly response: Response }> => {
|
||||||
|
const response = await fetch(url, { ...init, signal: abort.signal });
|
||||||
|
const body = (await response.json()) as unknown;
|
||||||
|
return { body, response };
|
||||||
|
})();
|
||||||
|
return withinTransportDeadline(operation, "Realtime integration finite request timed out.", () =>
|
||||||
|
abort.abort(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("native realtime over a live Node HTTP server", () => {
|
||||||
|
it("loads a real module and proves auth, snapshot, fanout, update, reconnect, and shutdown", async () => {
|
||||||
|
const control: FixtureControl = { events: [] };
|
||||||
|
const fixture = await createFixtureModule(control);
|
||||||
|
let runtime: StartedModuleRuntime | undefined;
|
||||||
|
let server: ServerType | undefined;
|
||||||
|
const connections: LiveSseConnection[] = [];
|
||||||
|
const failures: unknown[] = [];
|
||||||
|
try {
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: fixture.directory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
runtime = await startDiscoveredModuleRuntime(modules, services());
|
||||||
|
expect(control.events).toEqual(["register", "start"]);
|
||||||
|
const live = await bindLiveServer(runtime, composePlatformPermissionRegistry(modules));
|
||||||
|
server = live.server;
|
||||||
|
|
||||||
|
const anonymous = await requestJson(
|
||||||
|
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||||
|
);
|
||||||
|
expect(anonymous.response.status).toBe(403);
|
||||||
|
expect(anonymous.body).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
const missingPermission = await requestJson(
|
||||||
|
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||||
|
{ headers: { cookie: "ekklesia-session=denied" } },
|
||||||
|
);
|
||||||
|
expect(missingPermission.response.status).toBe(403);
|
||||||
|
|
||||||
|
const first = await connect(live.origin, "ekklesia-session=allowed");
|
||||||
|
const second = await connect(live.origin, "ekklesia-session=allowed");
|
||||||
|
connections.push(first.connection, second.connection);
|
||||||
|
expect(first.response.status).toBe(200);
|
||||||
|
expect(first.response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||||
|
|
||||||
|
const expectedInitial = {
|
||||||
|
currentSlide: 0,
|
||||||
|
mode: "on",
|
||||||
|
previousSlide: -1,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
};
|
||||||
|
expect(await first.connection.readEvent()).toEqual({
|
||||||
|
event: "snapshot",
|
||||||
|
payload: expectedInitial,
|
||||||
|
sequence: 1,
|
||||||
|
});
|
||||||
|
expect(await second.connection.readEvent()).toEqual({
|
||||||
|
event: "snapshot",
|
||||||
|
payload: expectedInitial,
|
||||||
|
sequence: 1,
|
||||||
|
});
|
||||||
|
expect(live.activeSubscriptions()).toBe(2);
|
||||||
|
|
||||||
|
control.publisher?.publish("state", {
|
||||||
|
currentSlide: 1,
|
||||||
|
mode: "on",
|
||||||
|
previousSlide: 0,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
});
|
||||||
|
const expectedUpdate = {
|
||||||
|
event: "update",
|
||||||
|
payload: {
|
||||||
|
currentSlide: 1,
|
||||||
|
mode: "on",
|
||||||
|
previousSlide: 0,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
},
|
||||||
|
sequence: 2,
|
||||||
|
};
|
||||||
|
expect(await first.connection.readEvent()).toEqual(expectedUpdate);
|
||||||
|
expect(await second.connection.readEvent()).toEqual(expectedUpdate);
|
||||||
|
|
||||||
|
await first.connection.close();
|
||||||
|
await waitForTransportCondition(
|
||||||
|
() => live.activeSubscriptions() === 1,
|
||||||
|
"Realtime integration subscriber cleanup timed out.",
|
||||||
|
);
|
||||||
|
control.publisher?.publish("state", {
|
||||||
|
currentSlide: 2,
|
||||||
|
mode: "off",
|
||||||
|
previousSlide: 1,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
});
|
||||||
|
expect(await second.connection.readEvent()).toMatchObject({ event: "update", sequence: 3 });
|
||||||
|
|
||||||
|
const reconnected = await connect(live.origin, "ekklesia-session=allowed");
|
||||||
|
connections.push(reconnected.connection);
|
||||||
|
expect(await reconnected.connection.readEvent()).toEqual({
|
||||||
|
event: "snapshot",
|
||||||
|
payload: {
|
||||||
|
currentSlide: 2,
|
||||||
|
mode: "off",
|
||||||
|
previousSlide: 1,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
},
|
||||||
|
sequence: 3,
|
||||||
|
});
|
||||||
|
expect(live.activeSubscriptions()).toBe(2);
|
||||||
|
|
||||||
|
const secondClosed = second.connection.expectClosed();
|
||||||
|
const reconnectedClosed = reconnected.connection.expectClosed();
|
||||||
|
runtime.realtime.closeSubscriptions();
|
||||||
|
await Promise.all([secondClosed, reconnectedClosed]);
|
||||||
|
await waitForTransportCondition(
|
||||||
|
() => live.activeSubscriptions() === 0,
|
||||||
|
"Realtime integration shutdown cleanup timed out.",
|
||||||
|
);
|
||||||
|
// Stream shutdown happens before ordinary HTTP drain in production. A publisher must remain
|
||||||
|
// usable until that drain completes so an already-running command can publish its result.
|
||||||
|
expect(() => control.publisher?.publish("state", null)).not.toThrow();
|
||||||
|
|
||||||
|
const afterShutdown = await requestJson(
|
||||||
|
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||||
|
{ headers: { cookie: "ekklesia-session=allowed" } },
|
||||||
|
);
|
||||||
|
expect(afterShutdown.response.status).toBe(503);
|
||||||
|
expect(afterShutdown.body).toEqual({ code: "REALTIME_UNAVAILABLE" });
|
||||||
|
await runtime.stop();
|
||||||
|
expect(() => control.publisher?.publish("state", null)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
runtime?.realtime.closeSubscriptions();
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
const connectionCleanup = await Promise.allSettled(
|
||||||
|
connections.map((connection) => connection.close()),
|
||||||
|
);
|
||||||
|
for (const result of connectionCleanup) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
failures.push(result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (server) {
|
||||||
|
try {
|
||||||
|
await closeServer(server);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runtime) {
|
||||||
|
try {
|
||||||
|
await runtime.stop();
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete (globalThis as unknown as Record<PropertyKey, unknown>)[fixture.symbol];
|
||||||
|
try {
|
||||||
|
await rm(fixture.directory, { force: true, recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw failures[0];
|
||||||
|
}
|
||||||
|
expect(control.events).toEqual(["register", "start", "stop"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
ModuleRealtimeError,
|
||||||
|
moduleIdSchema,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
parsePermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { createPermissionRegistry, type PermissionRegistry } from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import { type ModuleRealtimeRegistry, StagedModuleRealtimeRegistry } from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
MODULE_REALTIME_KEEPALIVE_MS,
|
||||||
|
MODULE_REALTIME_MAX_CONNECTION_AGE_MS,
|
||||||
|
ModuleRealtimeConfigurationError,
|
||||||
|
moduleRealtimeBaseUrl,
|
||||||
|
mountModuleRealtimeRoutes,
|
||||||
|
} from "../src/module-realtime-routes.js";
|
||||||
|
|
||||||
|
const moduleId = moduleIdSchema.parse("announcements");
|
||||||
|
const permission = parsePermissionId("announcements.slideshow.view");
|
||||||
|
const userId = parseEkklesiaUserId("88888888-8888-4888-8888-888888888888");
|
||||||
|
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||||
|
|
||||||
|
function permissionRegistry(): PermissionRegistry {
|
||||||
|
return createPermissionRegistry([
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: permission,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function realtimeFixture(initialPayload: unknown = { mode: "on" }): {
|
||||||
|
readonly publisher: ReturnType<StagedModuleRealtimeRegistry["publisherFor"]>;
|
||||||
|
readonly realtime: ModuleRealtimeRegistry;
|
||||||
|
} {
|
||||||
|
const staged = new StagedModuleRealtimeRegistry();
|
||||||
|
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||||
|
registration.realtime.channel({ channel: "state", permission });
|
||||||
|
registration.commit();
|
||||||
|
const publisher = staged.publisherFor(moduleId);
|
||||||
|
publisher.publish("state", initialPayload as never);
|
||||||
|
return { publisher, realtime: staged.publish() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appFor(
|
||||||
|
realtime: ModuleRealtimeRegistry,
|
||||||
|
options: {
|
||||||
|
readonly allowed?: boolean;
|
||||||
|
readonly keepaliveMs?: number;
|
||||||
|
readonly maxConnectionAgeMs?: number;
|
||||||
|
readonly signedIn?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
options.signedIn === false ? ANONYMOUS_PRINCIPAL : createAuthenticatedPrincipal(userId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
mountModuleRealtimeRoutes(app, {
|
||||||
|
evaluator: {
|
||||||
|
authorize: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ allowed: options.signedIn !== false && options.allowed !== false }),
|
||||||
|
},
|
||||||
|
...(options.keepaliveMs === undefined ? {} : { keepaliveMs: options.keepaliveMs }),
|
||||||
|
...(options.maxConnectionAgeMs === undefined
|
||||||
|
? {}
|
||||||
|
: { maxConnectionAgeMs: options.maxConnectionAgeMs }),
|
||||||
|
realtime,
|
||||||
|
registry: permissionRegistry(),
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
reader.read(),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error("Realtime test read timed out.")), 1_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return result.done ? "" : new TextDecoder().decode(result.value);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module realtime HTTP transport", () => {
|
||||||
|
it("builds one server-owned root and rejects an unenforceable channel snapshot", () => {
|
||||||
|
expect(moduleRealtimeBaseUrl("announcements")).toBe("/api/v1/module-realtime/announcements");
|
||||||
|
expect(MODULE_REALTIME_KEEPALIVE_MS).toBe(15_000);
|
||||||
|
expect(MODULE_REALTIME_MAX_CONNECTION_AGE_MS).toBe(300_000);
|
||||||
|
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
expect(() =>
|
||||||
|
mountModuleRealtimeRoutes(new Hono(), {
|
||||||
|
evaluator: { authorize: vi.fn() },
|
||||||
|
realtime,
|
||||||
|
registry: createPermissionRegistry([]),
|
||||||
|
runtime: { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn() },
|
||||||
|
}),
|
||||||
|
).toThrow(ModuleRealtimeConfigurationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies anonymous and unauthorized callers before allocating a subscription", async () => {
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||||
|
const observed: ModuleRealtimeRegistry = {
|
||||||
|
channels: realtime.channels,
|
||||||
|
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
|
||||||
|
const anonymous = await appFor(observed, { signedIn: false }).request(endpoint);
|
||||||
|
expect(anonymous.status).toBe(403);
|
||||||
|
expect(await anonymous.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
expect(subscribe).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const unauthorized = await appFor(observed, { allowed: false }).request(endpoint);
|
||||||
|
expect(unauthorized.status).toBe(403);
|
||||||
|
expect(await unauthorized.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
expect(subscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses Hono's implicit HEAD handling without allocating a stream subscription", async () => {
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||||
|
const observed: ModuleRealtimeRegistry = {
|
||||||
|
channels: realtime.channels,
|
||||||
|
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await appFor(observed).request(endpoint, { method: "HEAD" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(subscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens an authorized snapshot-first stream with proxy-safe headers and unchanged URLs", async () => {
|
||||||
|
const deliveryUrl =
|
||||||
|
"https://objects.example.test/slides/welcome.png?X-Amz-Signature=byte-identical";
|
||||||
|
const { realtime } = realtimeFixture({
|
||||||
|
currentSlide: 0,
|
||||||
|
mode: "on",
|
||||||
|
previousSlide: -1,
|
||||||
|
slides: [deliveryUrl],
|
||||||
|
});
|
||||||
|
const response = await appFor(realtime).request(endpoint);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("x-accel-buffering")).toBe("no");
|
||||||
|
expect(response.headers.get("vary")).toBe("Origin, Cookie");
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
expect(reader).toBeDefined();
|
||||||
|
const snapshot = await readChunk(reader as ReadableStreamDefaultReader<Uint8Array>);
|
||||||
|
expect(snapshot).toContain("event: snapshot\n");
|
||||||
|
expect(snapshot).toContain('"sequence":1');
|
||||||
|
expect(snapshot).toContain(deliveryUrl);
|
||||||
|
expect(snapshot).not.toContain("\nid:");
|
||||||
|
await reader?.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams ordered updates, emits transport-only keepalives, and closes on shutdown", async () => {
|
||||||
|
const { publisher, realtime } = realtimeFixture({ currentSlide: 0 });
|
||||||
|
const response = await appFor(realtime, { keepaliveMs: 5 }).request(endpoint);
|
||||||
|
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
|
||||||
|
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||||
|
publisher.publish("state", { currentSlide: 1 });
|
||||||
|
const update = await readChunk(reader);
|
||||||
|
expect(update).toContain("event: update");
|
||||||
|
expect(update).toContain('"sequence":2');
|
||||||
|
expect(update).toContain('"currentSlide":1');
|
||||||
|
expect(await readChunk(reader)).toBe(": keepalive\n\n");
|
||||||
|
|
||||||
|
const closed = reader.read();
|
||||||
|
realtime.closeSubscriptions();
|
||||||
|
await expect(closed).resolves.toEqual({ done: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a stream at the host-owned maximum age so reconnect reauthorizes", async () => {
|
||||||
|
const { realtime } = realtimeFixture({ currentSlide: 0 });
|
||||||
|
let activeSubscriptions = 0;
|
||||||
|
const observed: ModuleRealtimeRegistry = {
|
||||||
|
channels: realtime.channels,
|
||||||
|
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||||
|
subscribe: (owner, channel) => {
|
||||||
|
const subscription = realtime.subscribe(owner, channel);
|
||||||
|
activeSubscriptions += 1;
|
||||||
|
let closed = false;
|
||||||
|
return Object.freeze({
|
||||||
|
close: () => {
|
||||||
|
if (!closed) {
|
||||||
|
closed = true;
|
||||||
|
activeSubscriptions -= 1;
|
||||||
|
}
|
||||||
|
subscription.close();
|
||||||
|
},
|
||||||
|
read: () => subscription.read(),
|
||||||
|
snapshot: subscription.snapshot,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await appFor(observed, {
|
||||||
|
keepaliveMs: 1_000,
|
||||||
|
maxConnectionAgeMs: 25,
|
||||||
|
}).request(endpoint);
|
||||||
|
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
|
||||||
|
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||||
|
expect(activeSubscriptions).toBe(1);
|
||||||
|
expect(await readChunk(reader)).toBe("");
|
||||||
|
expect(activeSubscriptions).toBe(0);
|
||||||
|
const replacement = realtime.subscribe(moduleId, "state");
|
||||||
|
replacement.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose unknown module/channel combinations", async () => {
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
const app = appFor(realtime);
|
||||||
|
expect((await app.request("/api/v1/module-realtime/calendar/state")).status).toBe(404);
|
||||||
|
expect((await app.request("/api/v1/module-realtime/announcements/other")).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps subscriber-capacity and unexpected allocation failures to fixed non-secret responses", async () => {
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
const observed = (failure: unknown): ModuleRealtimeRegistry => ({
|
||||||
|
channels: realtime.channels,
|
||||||
|
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||||
|
subscribe: () => {
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const atCapacity = await appFor(
|
||||||
|
observed(new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT")),
|
||||||
|
).request(endpoint);
|
||||||
|
expect(atCapacity.status).toBe(503);
|
||||||
|
expect(await atCapacity.json()).toEqual({ code: "REALTIME_SUBSCRIBER_LIMIT" });
|
||||||
|
|
||||||
|
const detail = "secret-token-and-machine-path";
|
||||||
|
const unavailable = await appFor(observed(new Error(detail))).request(endpoint);
|
||||||
|
expect(unavailable.status).toBe(503);
|
||||||
|
const body = await unavailable.text();
|
||||||
|
expect(body).toBe('{"code":"REALTIME_UNAVAILABLE"}');
|
||||||
|
expect(body).not.toContain(detail);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,779 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationStore,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
startDiscoveredModuleRuntime,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
} from "@ekklesia/module-ui-host";
|
||||||
|
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
|
import {
|
||||||
|
bootstrapDisplayHost,
|
||||||
|
bootstrapHost,
|
||||||
|
createModuleUiContext,
|
||||||
|
type EkklesiaHostConfig,
|
||||||
|
moduleUiSourceUrl,
|
||||||
|
} from "@ekklesia/shell";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole chain, with nothing between the halves faked:
|
||||||
|
*
|
||||||
|
* ```text
|
||||||
|
* module fixture on disk -> discovery -> module runtime started -> publication registry
|
||||||
|
* -> inventory endpoint -> the shell's own bootstrap -> server-produced artifact URL
|
||||||
|
* -> the generic loader -> mount -> the module calls its own API relatively
|
||||||
|
* -> the shell resolves it to /api/v1/modules/<id>/... -> server permission enforcement
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Every step runs real code. The module is discovered and *started* by Core's real runtime, its
|
||||||
|
* routes are mounted by the real `createApiApp`, its artifact is published by the real ADR 0025
|
||||||
|
* route, its descriptor comes from the real inventory endpoint, and the browser half is
|
||||||
|
* `@ekklesia/shell`'s own exported protocol — the same functions the React shell calls — rather
|
||||||
|
* than a reimplementation of them here.
|
||||||
|
*
|
||||||
|
* Two things are deliberately *not* faked-around and are worth naming. The shell's `fetch` is the
|
||||||
|
* Hono app's own `request`, so requests really traverse routing, CORS, authorization middleware, and
|
||||||
|
* handlers without needing a socket. And Node cannot `import()` an `http:` URL, so the artifact is
|
||||||
|
* fetched over that same in-process HTTP path and the **bytes it returned** are handed to the
|
||||||
|
* generic loader through a `data:` URL — a genuine module URL, the same separation ADR 0025 used.
|
||||||
|
* No test here reads the artifact off the filesystem and calls that a proof of publication.
|
||||||
|
*/
|
||||||
|
const viewerId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real, complete module: a manifest declaring a restricted permission and a UI entrypoint, a
|
||||||
|
* server entrypoint that registers a permission-gated route, and a compiled UI artifact that reads
|
||||||
|
* its host context and calls its own API relatively.
|
||||||
|
*
|
||||||
|
* The UI artifact is written as plain ESM rather than compiled from TSX here: ADR 0023's pipeline is
|
||||||
|
* already proven end to end by `templates/module`'s own tests, and what this test needs is a byte
|
||||||
|
* sequence whose journey through publication, inventory, and the loader can be asserted exactly.
|
||||||
|
*/
|
||||||
|
async function createFixtureModule(
|
||||||
|
modulesDirectory: string,
|
||||||
|
id = "example",
|
||||||
|
name = "Example module",
|
||||||
|
withDisplay = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
if (withDisplay) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${id}`,
|
||||||
|
type: "module",
|
||||||
|
version: "0.1.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
ui: "./dist/ui/index.js",
|
||||||
|
...(withDisplay
|
||||||
|
? {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name,
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "Read example records.",
|
||||||
|
id: `${id}.records.read`,
|
||||||
|
},
|
||||||
|
...(withDisplay
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the example display.",
|
||||||
|
id: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
version: "0.1.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default {
|
||||||
|
id: ${JSON.stringify(id)},
|
||||||
|
version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/records",
|
||||||
|
permission: ${JSON.stringify(`${id}.records.read`)},
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
async handler() {
|
||||||
|
return Response.json([{ id: "a" }, { id: "b" }]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};\n`,
|
||||||
|
);
|
||||||
|
// The module UI: host-neutral, asks for a relative path, and renders from the host context. It
|
||||||
|
// hardcodes no origin, no prefix, and no permission decision of its own.
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
|
id: ${JSON.stringify(id)},
|
||||||
|
version: "0.1.0",
|
||||||
|
async mount(container, context) {
|
||||||
|
if (!context.permissions.has(${JSON.stringify(`${id}.records.read`)})) {
|
||||||
|
container.textContent = context.moduleId + ":denied";
|
||||||
|
return () => { container.textContent = ""; };
|
||||||
|
}
|
||||||
|
const response = await context.api.request("records");
|
||||||
|
const records = response.ok ? await response.json() : [];
|
||||||
|
container.textContent =
|
||||||
|
context.moduleId + ":" + response.status + ":" + records.length;
|
||||||
|
return () => { container.textContent = ""; };
|
||||||
|
},
|
||||||
|
};\n`,
|
||||||
|
);
|
||||||
|
if (withDisplay) {
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
|
id: ${JSON.stringify(id)},
|
||||||
|
version: "0.1.0",
|
||||||
|
async mount(container, context) {
|
||||||
|
const response = await context.api.request("records");
|
||||||
|
const records = response.ok ? await response.json() : [];
|
||||||
|
container.textContent =
|
||||||
|
context.moduleId + ":display:" + response.status + ":" + records.length;
|
||||||
|
return () => { container.textContent = ""; };
|
||||||
|
},
|
||||||
|
};\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HarnessOptions {
|
||||||
|
readonly display?: boolean;
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
readonly grants?: readonly string[];
|
||||||
|
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
|
||||||
|
* single module proves very little. */
|
||||||
|
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
|
||||||
|
readonly platformOwner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
readonly config: EkklesiaHostConfig;
|
||||||
|
readonly moduleApiCalls: readonly string[];
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a real API over a real discovered-and-started module, and returns the `EkklesiaHostConfig`
|
||||||
|
* a browser would use — with `fetch` wired to the app itself.
|
||||||
|
*/
|
||||||
|
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
|
||||||
|
for (const extra of options.extraModules ?? []) {
|
||||||
|
await createFixtureModule(modulesDirectory, extra.id, extra.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||||
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
|
});
|
||||||
|
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
||||||
|
// route snapshot below is the one it actually published.
|
||||||
|
const moduleRuntime = await startDiscoveredModuleRuntime(modules, services);
|
||||||
|
|
||||||
|
const granted = new Set(options.grants ?? []);
|
||||||
|
const evaluator = createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
||||||
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
|
||||||
|
const moduleUi = createModuleUiPublicationRegistry(modules);
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
|
moduleRoutes: moduleRuntime.routes,
|
||||||
|
moduleRealtime: moduleRuntime.realtime,
|
||||||
|
moduleServices: services,
|
||||||
|
moduleUi,
|
||||||
|
...(moduleUi.listDisplays().length === 0
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
displayHostAssets: createDisplayHostAssets(
|
||||||
|
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||||
|
"export const displayHost = true;\n",
|
||||||
|
"export const displayWorker = true;\n",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: ["https://dashboard.example.test"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleApiCalls: string[] = [];
|
||||||
|
const hostFetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
if (url.pathname.startsWith("/api/v1/modules/")) {
|
||||||
|
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
|
||||||
|
}
|
||||||
|
return app.request(url.href, init);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: { apiBaseUrl: "https://ekklesia.example.test", fetch: hostFetch },
|
||||||
|
moduleApiCalls,
|
||||||
|
stop: () => moduleRuntime.stop(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node cannot `import()` an `http:` URL, so the artifact is fetched over the real route and its
|
||||||
|
* exact bytes become a `data:` URL. The loader still performs a genuine dynamic import of a real
|
||||||
|
* module URL; only the transport differs, and the transport itself is asserted separately. */
|
||||||
|
async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promise<string> {
|
||||||
|
const response = await (harness.config.fetch as typeof fetch)(sourceUrl);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
|
||||||
|
* test environment.
|
||||||
|
*
|
||||||
|
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
|
||||||
|
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
|
||||||
|
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
|
||||||
|
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
|
||||||
|
* dynamically imports real module server entrypoints from temporary directories, which then fail to
|
||||||
|
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
|
||||||
|
* this server-side suite depends on exactly as Node provides it.
|
||||||
|
*/
|
||||||
|
function containerSink(): HostContainer {
|
||||||
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostContainer {
|
||||||
|
readonly children: { readonly length: number };
|
||||||
|
querySelector(selectors: string): HostContainer | null;
|
||||||
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||||
|
function mountedText(container: HostContainer): string {
|
||||||
|
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||||
|
|
||||||
|
describe("end to end: a discovered module's UI mounts in the Ekklesia host", () => {
|
||||||
|
it("bootstraps, publishes, loads, mounts, and reaches the module's own API", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
grants: ["example.records.read"],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// 1. The shell's own bootstrap: principal, then inventory.
|
||||||
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
|
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId });
|
||||||
|
// A module nobody has arranged yet is ungrouped and visible — no initialization write.
|
||||||
|
expect(bootstrap.navigation.groups).toEqual([]);
|
||||||
|
expect(bootstrap.navigation.ungrouped).toHaveLength(1);
|
||||||
|
expect(bootstrap.navigation.ungrouped[0]?.name).toBe("Example module");
|
||||||
|
|
||||||
|
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||||
|
if (!descriptor) {
|
||||||
|
throw new Error("the inventory returned no module");
|
||||||
|
}
|
||||||
|
// 2. The URLs came from the server, not from anything the browser composed.
|
||||||
|
expect(descriptor).toEqual({
|
||||||
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
|
id: "example",
|
||||||
|
permissions: ["example.records.read"],
|
||||||
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
|
uiUrl: "/api/v1/module-ui/example/0.1.0",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. The artifact really is served by the real publication route at that URL.
|
||||||
|
const sourceUrl = moduleUiSourceUrl(harness.config, descriptor);
|
||||||
|
expect(sourceUrl).toBe("https://ekklesia.example.test/api/v1/module-ui/example/0.1.0");
|
||||||
|
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||||
|
|
||||||
|
// 4. The generic loader mounts it with the context the shell built.
|
||||||
|
const container = containerSink();
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
container: container as unknown as LoaderContainer,
|
||||||
|
context: createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor,
|
||||||
|
principal: bootstrap.principal,
|
||||||
|
}),
|
||||||
|
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. The module rendered, having reached its own backend through the host.
|
||||||
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
|
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
||||||
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
|
|
||||||
|
// 7. And it tears down cleanly.
|
||||||
|
await mounted.unmount();
|
||||||
|
expect(mountedText(container)).toBe("");
|
||||||
|
expect(mounted.mounted).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the stable display route, resolves its real descriptor, and mounts the published display bytes", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
display: true,
|
||||||
|
grants: ["example.records.read", "example.display.view"],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// 1. A device keeps this version-independent host URL. It is a real permission-gated API route,
|
||||||
|
// not a test-only HTML wrapper around the browser functions below.
|
||||||
|
const page = await (harness.config.fetch as typeof fetch)(
|
||||||
|
"https://ekklesia.example.test/display/example",
|
||||||
|
);
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(await page.text()).toContain("/assets/ekklesia-display/app.js");
|
||||||
|
|
||||||
|
// 2. The display surface's own bootstrap resolves principal first, then the singular protected
|
||||||
|
// descriptor. The browser constructs no artifact, API, or realtime URL itself.
|
||||||
|
const bootstrap = await bootstrapDisplayHost(harness.config, "example");
|
||||||
|
expect(bootstrap.status).toBe("ready");
|
||||||
|
if (bootstrap.status !== "ready") {
|
||||||
|
throw new Error("the authenticated display did not resolve");
|
||||||
|
}
|
||||||
|
expect(bootstrap.descriptor).toEqual({
|
||||||
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
|
displayPageUrl: "/display/example",
|
||||||
|
id: "example",
|
||||||
|
permissions: ["example.records.read", "example.display.view"],
|
||||||
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
|
uiUrl: "/api/v1/module-ui/example/0.1.0/display",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Fetch the exact role-specific bytes from the real publication route, then genuinely import
|
||||||
|
// those returned bytes. Reading the fixture file directly would not prove publication.
|
||||||
|
const sourceUrl = moduleUiSourceUrl(harness.config, bootstrap.descriptor);
|
||||||
|
expect(sourceUrl).toBe(
|
||||||
|
"https://ekklesia.example.test/api/v1/module-ui/example/0.1.0/display",
|
||||||
|
);
|
||||||
|
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||||
|
|
||||||
|
// 4. The generic loader receives the host-owned browser context, including the canonical
|
||||||
|
// display page, and mounts the display artifact independently of the Dashboard artifact.
|
||||||
|
const context = createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor: bootstrap.descriptor,
|
||||||
|
principal: bootstrap.principal,
|
||||||
|
});
|
||||||
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
|
"api",
|
||||||
|
"contributions",
|
||||||
|
"display",
|
||||||
|
"moduleId",
|
||||||
|
"permissions",
|
||||||
|
"principal",
|
||||||
|
"realtime",
|
||||||
|
]);
|
||||||
|
const container = containerSink();
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
container: container as unknown as LoaderContainer,
|
||||||
|
context,
|
||||||
|
source: {
|
||||||
|
moduleId: bootstrap.descriptor.id,
|
||||||
|
url: artifactUrl,
|
||||||
|
version: bootstrap.descriptor.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mountedText(container)).toBe("example:display:200:2");
|
||||||
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
|
await mounted.unmount();
|
||||||
|
expect(mountedText(container)).toBe("");
|
||||||
|
expect(mounted.mounted).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
|
||||||
|
// Signed in, but holding no grant for the module's restricted permission.
|
||||||
|
const harness = await startHarness({
|
||||||
|
grants: [],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
|
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||||
|
if (!descriptor) {
|
||||||
|
throw new Error("the inventory returned no module");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hint is honest about it...
|
||||||
|
expect(descriptor.permissions).toEqual([]);
|
||||||
|
const context = createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor,
|
||||||
|
principal: bootstrap.principal,
|
||||||
|
});
|
||||||
|
expect(context.permissions.has("example.records.read")).toBe(false);
|
||||||
|
|
||||||
|
// ...and the hint is not what protects the route. Calling anyway — exactly what a buggy or
|
||||||
|
// hostile UI would do — is still refused by the server.
|
||||||
|
const response = await context.api.request("records");
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the module's own denied state from the hint, without calling the API", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
grants: [],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
|
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||||
|
if (!descriptor) {
|
||||||
|
throw new Error("the inventory returned no module");
|
||||||
|
}
|
||||||
|
const artifactUrl = await publishedArtifactUrl(
|
||||||
|
harness,
|
||||||
|
moduleUiSourceUrl(harness.config, descriptor),
|
||||||
|
);
|
||||||
|
const container = containerSink();
|
||||||
|
|
||||||
|
await loadModuleUi({
|
||||||
|
container: container as unknown as LoaderContainer,
|
||||||
|
context: createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor,
|
||||||
|
principal: bootstrap.principal,
|
||||||
|
}),
|
||||||
|
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hiding a control is a usability improvement: the module simply did not ask.
|
||||||
|
expect(mountedText(container)).toBe("example:denied");
|
||||||
|
expect(harness.moduleApiCalls).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a configured arrangement, and a move changes presentation only", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
extraModules: [
|
||||||
|
{ id: "video", name: "Video" },
|
||||||
|
{ id: "calendar", name: "Calendar" },
|
||||||
|
],
|
||||||
|
grants: ["example.records.read"],
|
||||||
|
platformOwner: true,
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
const organize = (url: string, body: unknown) =>
|
||||||
|
(harness.config.fetch as typeof fetch)(`https://ekklesia.example.test${url}`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// An administrator organizes the Dashboard, through the real mutation API.
|
||||||
|
await organize("/api/v1/dashboard/navigation/groups/media", { label: "Media", order: 10 });
|
||||||
|
await organize("/api/v1/dashboard/navigation/groups/planning", {
|
||||||
|
label: "Planning",
|
||||||
|
order: 20,
|
||||||
|
});
|
||||||
|
await organize("/api/v1/dashboard/navigation/placements/video", {
|
||||||
|
groupId: "media",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
await organize("/api/v1/dashboard/navigation/placements/example", {
|
||||||
|
groupId: "media",
|
||||||
|
order: 20,
|
||||||
|
});
|
||||||
|
await organize("/api/v1/dashboard/navigation/placements/calendar", {
|
||||||
|
groupId: "planning",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell's own bootstrap sees exactly that arrangement.
|
||||||
|
const before = await bootstrapHost(harness.config);
|
||||||
|
expect(
|
||||||
|
before.navigation.groups.map((group) => [
|
||||||
|
group.label,
|
||||||
|
group.modules.map((entry) => entry.name),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
["Media", ["Video", "Example module"]],
|
||||||
|
["Planning", ["Calendar"]],
|
||||||
|
]);
|
||||||
|
expect(before.navigation.ungrouped).toEqual([]);
|
||||||
|
|
||||||
|
// Mounting from inside a group uses the descriptor the group carried, untouched.
|
||||||
|
const groupedEntry = before.navigation.groups[0]?.modules[1];
|
||||||
|
if (!groupedEntry) {
|
||||||
|
throw new Error("the arrangement did not contain the expected module");
|
||||||
|
}
|
||||||
|
const container = containerSink();
|
||||||
|
const mounted = await loadModuleUi({
|
||||||
|
container: container as unknown as LoaderContainer,
|
||||||
|
context: createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor: groupedEntry.descriptor,
|
||||||
|
principal: before.principal,
|
||||||
|
}),
|
||||||
|
source: {
|
||||||
|
moduleId: groupedEntry.descriptor.id,
|
||||||
|
url: await publishedArtifactUrl(
|
||||||
|
harness,
|
||||||
|
moduleUiSourceUrl(harness.config, groupedEntry.descriptor),
|
||||||
|
),
|
||||||
|
version: groupedEntry.descriptor.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
|
await mounted.unmount();
|
||||||
|
|
||||||
|
// Now move it somewhere else entirely.
|
||||||
|
await organize("/api/v1/dashboard/navigation/groups/sunday-production", {
|
||||||
|
label: "Sunday Production",
|
||||||
|
order: 5,
|
||||||
|
});
|
||||||
|
await organize("/api/v1/dashboard/navigation/placements/example", {
|
||||||
|
groupId: "sunday-production",
|
||||||
|
order: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await bootstrapHost(harness.config);
|
||||||
|
expect(
|
||||||
|
after.navigation.groups.map((group) => [
|
||||||
|
group.label,
|
||||||
|
group.modules.map((entry) => entry.name),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
["Sunday Production", ["Example module"]],
|
||||||
|
["Media", ["Video"]],
|
||||||
|
["Planning", ["Calendar"]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const movedEntry = after.navigation.groups[0]?.modules[0];
|
||||||
|
if (!movedEntry) {
|
||||||
|
throw new Error("the moved module was not found in its new group");
|
||||||
|
}
|
||||||
|
// The whole point of the pass: the module is byte-identical across the move — same artifact
|
||||||
|
// URL, same API root, same permissions, same version, same name.
|
||||||
|
expect(movedEntry).toEqual(groupedEntry);
|
||||||
|
|
||||||
|
// And it still mounts the same artifact and reaches the same API.
|
||||||
|
const callsBeforeRemount = harness.moduleApiCalls.length;
|
||||||
|
const movedContainer = containerSink();
|
||||||
|
const remounted = await loadModuleUi({
|
||||||
|
container: movedContainer as unknown as LoaderContainer,
|
||||||
|
context: createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor: movedEntry.descriptor,
|
||||||
|
principal: after.principal,
|
||||||
|
}),
|
||||||
|
source: {
|
||||||
|
moduleId: movedEntry.descriptor.id,
|
||||||
|
url: await publishedArtifactUrl(
|
||||||
|
harness,
|
||||||
|
moduleUiSourceUrl(harness.config, movedEntry.descriptor),
|
||||||
|
),
|
||||||
|
version: movedEntry.descriptor.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||||
|
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||||
|
"GET /api/v1/modules/example/records",
|
||||||
|
]);
|
||||||
|
await remounted.unmount();
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a newly installed module visible beside an existing arrangement", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
extraModules: [{ id: "video", name: "Video" }],
|
||||||
|
platformOwner: true,
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await (harness.config.fetch as typeof fetch)(
|
||||||
|
"https://ekklesia.example.test/api/v1/dashboard/navigation/groups/media",
|
||||||
|
{
|
||||||
|
body: JSON.stringify({ label: "Media", order: 10 }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
origin: "https://dashboard.example.test",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await (harness.config.fetch as typeof fetch)(
|
||||||
|
"https://ekklesia.example.test/api/v1/dashboard/navigation/placements/video",
|
||||||
|
{
|
||||||
|
body: JSON.stringify({ groupId: "media", order: 10 }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
origin: "https://dashboard.example.test",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
|
|
||||||
|
expect(bootstrap.navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
|
||||||
|
// `example` was never placed, and is still reachable rather than invisible.
|
||||||
|
expect(bootstrap.navigation.ungrouped.map((entry) => entry.name)).toEqual(["Example module"]);
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
|
||||||
|
const harness = await startHarness();
|
||||||
|
try {
|
||||||
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
|
|
||||||
|
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
|
||||||
|
expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] });
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a module UI that tries to address another module's API through the host", async () => {
|
||||||
|
const harness = await startHarness({
|
||||||
|
grants: ["example.records.read"],
|
||||||
|
principal: createAuthenticatedPrincipal(viewerId),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const bootstrap = await bootstrapHost(harness.config);
|
||||||
|
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
|
||||||
|
if (!descriptor) {
|
||||||
|
throw new Error("the inventory returned no module");
|
||||||
|
}
|
||||||
|
const context = createModuleUiContext({
|
||||||
|
config: harness.config,
|
||||||
|
descriptor,
|
||||||
|
principal: bootstrap.principal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(context.api.request("../../grants?userId=someone")).rejects.toMatchObject({
|
||||||
|
code: "MODULE_API_PATH_INVALID",
|
||||||
|
});
|
||||||
|
// It never became a request at all.
|
||||||
|
expect(harness.moduleApiCalls).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await harness.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationStore,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
type PermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||||
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version?: string;
|
||||||
|
/** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */
|
||||||
|
readonly withUi?: boolean;
|
||||||
|
readonly permissions?: ReadonlyArray<{
|
||||||
|
readonly id: string;
|
||||||
|
readonly baseline: "authenticated" | "public" | "restricted";
|
||||||
|
}>;
|
||||||
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||||
|
readonly contributions?: readonly {
|
||||||
|
readonly localName: string;
|
||||||
|
readonly capability: string;
|
||||||
|
readonly capabilityRange: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
|
const version = fixture.version ?? "0.1.0";
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, fixture.id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${fixture.id}`,
|
||||||
|
type: "module",
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
|
},
|
||||||
|
capabilities: fixture.capabilities ?? [],
|
||||||
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||||
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: `${fixture.id}.${contribution.localName}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
})),
|
||||||
|
id: fixture.id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: `${fixture.id} module`,
|
||||||
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||||
|
...permission,
|
||||||
|
description: `Test permission for ${fixture.id}.`,
|
||||||
|
})),
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
|
||||||
|
);
|
||||||
|
if (fixture.withUi) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const contribution of fixture.contributions ?? []) {
|
||||||
|
const contributionDirectory = path.join(
|
||||||
|
moduleDirectory,
|
||||||
|
"dist/contributions",
|
||||||
|
contribution.localName,
|
||||||
|
);
|
||||||
|
await mkdir(contributionDirectory, { recursive: true });
|
||||||
|
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallOptions {
|
||||||
|
readonly fixtures: readonly ModuleFixture[];
|
||||||
|
/** Anonymous by default, so every scenario has to opt in to being signed in. */
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
/** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so
|
||||||
|
* baselines, the owner bypass, and deny-by-default all behave exactly as in production. */
|
||||||
|
readonly grants?: readonly string[];
|
||||||
|
readonly platformOwner?: boolean;
|
||||||
|
/** Makes the grant port fail, so the "a hint that could not be determined is never reported as
|
||||||
|
* denied" path can be exercised. */
|
||||||
|
readonly grantsUnavailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly app: Hono;
|
||||||
|
readonly modules: readonly DiscoveredModule[];
|
||||||
|
readonly modulesDirectory: string;
|
||||||
|
readonly permissionRegistry: PermissionRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A real installation: fixtures on disk, Core's real discovery, real permission composition, the
|
||||||
|
* real publication registry, the real authorization evaluator, and the real `createApiApp`. Only
|
||||||
|
* identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL
|
||||||
|
* to answer the questions these tests ask. */
|
||||||
|
async function install(options: InstallOptions): Promise<Installation> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
for (const fixture of options.fixtures) {
|
||||||
|
await createModule(modulesDirectory, fixture);
|
||||||
|
}
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const granted = new Set(options.grants ?? []);
|
||||||
|
const evaluator = createAuthorizationEvaluator({
|
||||||
|
grants: {
|
||||||
|
hasActiveGrant: async (query) => {
|
||||||
|
if (options.grantsUnavailable) {
|
||||||
|
throw new Error("grant store unavailable");
|
||||||
|
}
|
||||||
|
return granted.has(query.permission);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||||
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
|
});
|
||||||
|
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
||||||
|
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: { evaluator, registry: permissionRegistry, store },
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: services,
|
||||||
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, modules, modulesDirectory, permissionRegistry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(): EkklesiaPrincipal {
|
||||||
|
return createAuthenticatedPrincipal(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inventoryOf(app: Hono): Promise<{
|
||||||
|
readonly body: string;
|
||||||
|
readonly modules: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
readonly response: Response;
|
||||||
|
}> {
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
const body = await response.clone().text();
|
||||||
|
const parsed = (await response.json()) as { modules?: ReadonlyArray<Record<string, unknown>> };
|
||||||
|
return { body, modules: parsed.modules ?? [], response };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module UI inventory — what it lists", () => {
|
||||||
|
it("describes a publishable module with exactly the fields a host needs", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [], withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules, response } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(modules).toEqual([
|
||||||
|
{
|
||||||
|
contributions: [],
|
||||||
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
|
display: null,
|
||||||
|
id: "example",
|
||||||
|
permissions: [],
|
||||||
|
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||||
|
ui: { url: "/api/v1/module-ui/example/0.1.0" },
|
||||||
|
version: "0.1.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds all host URLs with the server's own canonical helpers", async () => {
|
||||||
|
const { app, modules: discovered } = await install({
|
||||||
|
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
const module = discovered[0] as DiscoveredModule;
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
// Not "looks like the right shape" — byte-identical to what the helpers produce, so a second
|
||||||
|
// hand-written template string anywhere would fail here.
|
||||||
|
expect(modules[0]?.ui).toEqual({
|
||||||
|
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
|
||||||
|
});
|
||||||
|
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
|
||||||
|
expect(modules[0]?.realtime).toEqual({
|
||||||
|
baseUrl: moduleRealtimeBaseUrl(module.manifest.id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a module that declares no UI entrypoint", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "calendar" }, { id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.map((module) => module.id)).toEqual(["example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty, not an error, when no installed module provides a UI", async () => {
|
||||||
|
const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() });
|
||||||
|
|
||||||
|
const { modules, response } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(modules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders modules deterministically, matching the publication registry", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{ id: "signage", withUi: true },
|
||||||
|
{ id: "announcements", withUi: true },
|
||||||
|
{ id: "calendar", withUi: true },
|
||||||
|
],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await inventoryOf(app);
|
||||||
|
const second = await inventoryOf(app);
|
||||||
|
|
||||||
|
// Discovery order, which is sorted; the same on every request.
|
||||||
|
expect(first.modules.map((module) => module.id)).toEqual([
|
||||||
|
"announcements",
|
||||||
|
"calendar",
|
||||||
|
"signage",
|
||||||
|
]);
|
||||||
|
expect(second.modules.map((module) => module.id)).toEqual(
|
||||||
|
first.modules.map((module) => module.id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaks no filesystem path, package root, or other manifest internal", async () => {
|
||||||
|
const {
|
||||||
|
app,
|
||||||
|
modules: discovered,
|
||||||
|
modulesDirectory,
|
||||||
|
} = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [], withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string;
|
||||||
|
|
||||||
|
const { body, modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(body).not.toContain(modulesDirectory);
|
||||||
|
expect(body).not.toContain(artifactPath);
|
||||||
|
expect(body).not.toContain(tmpdir());
|
||||||
|
expect(body).not.toContain("dist");
|
||||||
|
expect(body).not.toContain("@test/module-example");
|
||||||
|
expect(body).not.toContain("ekklesia.module.json");
|
||||||
|
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
||||||
|
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||||
|
"api",
|
||||||
|
"contributions",
|
||||||
|
"display",
|
||||||
|
"id",
|
||||||
|
"permissions",
|
||||||
|
"realtime",
|
||||||
|
"ui",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no navigation or presentation metadata", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body } = await inventoryOf(app);
|
||||||
|
|
||||||
|
// ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module
|
||||||
|
// declaration. The manifest's own `name` and `description` must not leak into it by accident.
|
||||||
|
for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) {
|
||||||
|
expect(body, field).not.toContain(`"${field}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module UI inventory — authentication", () => {
|
||||||
|
it("refuses an anonymous caller", async () => {
|
||||||
|
const { app } = await install({ fixtures: [{ id: "example", withUi: true }] });
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells an anonymous caller nothing about what is installed", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{ id: "example", withUi: true },
|
||||||
|
{ id: "calendar", withUi: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
|
||||||
|
|
||||||
|
expect(body).not.toContain("example");
|
||||||
|
expect(body).not.toContain("calendar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits any signed-in user without needing an explicit grant", async () => {
|
||||||
|
// `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell
|
||||||
|
// that rendered nothing until an operator granted every user a permission would be unusable.
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
grants: [],
|
||||||
|
principal: signedIn(),
|
||||||
|
platformOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is never cached and varies on the caller's identity", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
const vary = response.headers.get("vary")?.toLowerCase() ?? "";
|
||||||
|
expect(vary).toContain("origin");
|
||||||
|
expect(vary).toContain("cookie");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module UI inventory — permission hints", () => {
|
||||||
|
const readPermission = { baseline: "restricted" as const, id: "example.records.read" };
|
||||||
|
const writePermission = { baseline: "restricted" as const, id: "example.records.write" };
|
||||||
|
|
||||||
|
it("returns only the permissions the server actually granted this principal", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||||
|
grants: ["example.records.read"],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules[0]?.permissions).toEqual(["example.records.read"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none of them for a signed-in user holding no grant", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||||
|
grants: [],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
// Never a default of true, and never the module's full declared surface.
|
||||||
|
expect(modules[0]?.permissions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a baseline that needs no grant at all", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{
|
||||||
|
id: "example",
|
||||||
|
permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission],
|
||||||
|
withUi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grants: [],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules[0]?.permissions).toEqual(["example.records.list"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours the platform-owner bypass, exactly like a real request would", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||||
|
grants: [],
|
||||||
|
platformOwner: true,
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes hints to each module's own declared permissions", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{ id: "example", permissions: [readPermission], withUi: true },
|
||||||
|
{
|
||||||
|
id: "calendar",
|
||||||
|
permissions: [{ baseline: "restricted", id: "calendar.events.read" }],
|
||||||
|
withUi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grants: ["example.records.read", "calendar.events.read"],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
const byId = new Map(modules.map((module) => [module.id, module.permissions]));
|
||||||
|
// Neither module learns anything about the other's authorization state, and neither is told
|
||||||
|
// about Core's own permissions.
|
||||||
|
expect(byId.get("example")).toEqual(["example.records.read"]);
|
||||||
|
expect(byId.get("calendar")).toEqual(["calendar.events.read"]);
|
||||||
|
expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage");
|
||||||
|
expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [readPermission], withUi: true }],
|
||||||
|
grantsUnavailable: true,
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
|
||||||
|
// A hint that silently read "denied" during an outage would blank the UI and look like a
|
||||||
|
// permissions problem.
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module UI inventory — it is not a module management API", () => {
|
||||||
|
it("routes no method other than GET", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH, { method });
|
||||||
|
expect(response.status, method).toBe(404);
|
||||||
|
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses to serve an artifact under the collection path", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) {
|
||||||
|
const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`);
|
||||||
|
expect(response.status, suffix).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inventory is how a consuming module learns what was contributed to its own capabilities
|
||||||
|
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
|
||||||
|
*/
|
||||||
|
describe("module UI inventory — contributions", () => {
|
||||||
|
const signage = {
|
||||||
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||||
|
id: "signage",
|
||||||
|
permissions: [],
|
||||||
|
withUi: true,
|
||||||
|
};
|
||||||
|
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||||
|
contributions: [
|
||||||
|
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
|
||||||
|
],
|
||||||
|
id: "announcements",
|
||||||
|
permissions: [],
|
||||||
|
withUi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the providing module the contribution and its server-produced URL", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
const consumer = modules.find((module) => module.id === "signage");
|
||||||
|
|
||||||
|
expect(consumer?.contributions).toEqual([
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
id: "announcements.signage-slide",
|
||||||
|
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds a contribution whose provider is outside its declared range", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements("^2.0.0")],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
|
||||||
|
const { app, modulesDirectory } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body, modules } = await inventoryOf(app);
|
||||||
|
const consumer = modules.find((module) => module.id === "signage");
|
||||||
|
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
|
||||||
|
|
||||||
|
expect(body).not.toContain(modulesDirectory);
|
||||||
|
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
|
||||||
|
"capability",
|
||||||
|
"id",
|
||||||
|
"url",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
|
||||||
|
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
|
||||||
|
export function testDashboardHostAssets(): DashboardHostAssets {
|
||||||
|
return createDashboardHostAssets(
|
||||||
|
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||||
|
"export const dashboardHost = true;\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
DashboardConfiguration,
|
||||||
|
DashboardGroupRecord,
|
||||||
|
DashboardPlacementRecord,
|
||||||
|
DashboardStore,
|
||||||
|
SetDashboardPlacementInput,
|
||||||
|
UpsertDashboardGroupInput,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
|
import { DashboardStoreError } from "@ekklesia/core/dashboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory `DashboardStore` for tests that exercise **routing and resolution**, not persistence.
|
||||||
|
*
|
||||||
|
* It reproduces only the two behaviours those tests depend on — a placement may not name a group
|
||||||
|
* that does not exist, and deleting a group ungroups its modules (the schema's
|
||||||
|
* `on delete set null`) — and deliberately reproduces nothing else. Everything the real store owns
|
||||||
|
* that this double would only be guessing at (identifier grammar, label trimming and bounds,
|
||||||
|
* ordering bounds, transactional behaviour, and the actual `on delete set null` constraint) is
|
||||||
|
* proven against real PostgreSQL in `core/tests/dashboard-store.integration.ts` instead, so this
|
||||||
|
* double can never quietly stand in for a persistence guarantee nobody checked.
|
||||||
|
*/
|
||||||
|
export function createInMemoryDashboardStore(
|
||||||
|
seed: Partial<DashboardConfiguration> = {},
|
||||||
|
): DashboardStore {
|
||||||
|
const groups = new Map<string, DashboardGroupRecord>(
|
||||||
|
(seed.groups ?? []).map((group) => [group.id, { ...group }] as const),
|
||||||
|
);
|
||||||
|
const placements = new Map<string, DashboardPlacementRecord>(
|
||||||
|
(seed.placements ?? []).map((placement) => [placement.moduleId, { ...placement }] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = <Record extends { readonly sortOrder: number }>(
|
||||||
|
records: readonly Record[],
|
||||||
|
key: (record: Record) => string,
|
||||||
|
): readonly Record[] =>
|
||||||
|
Object.freeze(
|
||||||
|
[...records].sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.sortOrder - right.sortOrder ||
|
||||||
|
(key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
async deleteGroup(groupId: string): Promise<void> {
|
||||||
|
if (!groups.delete(groupId)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
for (const [moduleId, placement] of placements) {
|
||||||
|
if (placement.groupId === groupId) {
|
||||||
|
placements.set(moduleId, { ...placement, groupId: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async readConfiguration(): Promise<DashboardConfiguration> {
|
||||||
|
return Object.freeze({
|
||||||
|
groups: sorted([...groups.values()], (group) => group.id),
|
||||||
|
placements: sorted([...placements.values()], (placement) => placement.moduleId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
|
||||||
|
if (input.groupId !== null && !groups.has(input.groupId)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
const record: DashboardPlacementRecord = Object.freeze({
|
||||||
|
groupId: input.groupId,
|
||||||
|
moduleId: input.moduleId,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
});
|
||||||
|
placements.set(input.moduleId, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
|
||||||
|
const record: DashboardGroupRecord = Object.freeze({
|
||||||
|
id: input.id,
|
||||||
|
label: input.label.trim(),
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
});
|
||||||
|
groups.set(input.id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
|||||||
|
import type { ModuleEnablementRecord } from "@ekklesia/contracts";
|
||||||
|
import type {
|
||||||
|
DiscoveredModule,
|
||||||
|
ModuleLifecycleConfiguration,
|
||||||
|
ModuleLifecycleSnapshot,
|
||||||
|
ModuleLifecycleStore,
|
||||||
|
SetModuleEnabledInput,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { ModuleLifecycleStoreError, resolveModuleEligibility } from "@ekklesia/core/modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory `ModuleLifecycleStore` for tests that exercise **routing and resolution**, not
|
||||||
|
* persistence.
|
||||||
|
*
|
||||||
|
* It reproduces only what those tests depend on — records survive a write and read back in
|
||||||
|
* module-id order, and an invalid identifier is rejected — and deliberately reproduces nothing
|
||||||
|
* else. The real table's constraints, the upsert's conflict target, and the fact that a record for
|
||||||
|
* an undeployed module is retained are proven against real PostgreSQL in
|
||||||
|
* `core/tests/module-lifecycle-store.integration.ts`, so this double can never quietly stand in for
|
||||||
|
* a persistence guarantee nobody checked.
|
||||||
|
*/
|
||||||
|
export function createInMemoryModuleLifecycleStore(
|
||||||
|
seed: readonly ModuleEnablementRecord[] = [],
|
||||||
|
): ModuleLifecycleStore {
|
||||||
|
const records = new Map<string, boolean>(
|
||||||
|
seed.map((record) => [record.moduleId, record.enabled] as const),
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
|
||||||
|
return Object.freeze({
|
||||||
|
modules: Object.freeze(
|
||||||
|
[...records.entries()]
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
.map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
|
||||||
|
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) {
|
||||||
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
|
||||||
|
}
|
||||||
|
records.set(input.moduleId, input.enabled);
|
||||||
|
return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered
|
||||||
|
* inventory and the stored configuration, then record what would have started — rather than
|
||||||
|
* hand-writing one, so a test can never assert against a snapshot the real composition would never
|
||||||
|
* produce.
|
||||||
|
*/
|
||||||
|
export function moduleLifecycleSnapshotFor(
|
||||||
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
|
configuration: ModuleLifecycleConfiguration = { modules: [] },
|
||||||
|
): ModuleLifecycleSnapshot {
|
||||||
|
const resolution = resolveModuleEligibility(discoveredModules, configuration);
|
||||||
|
return Object.freeze({
|
||||||
|
discoveredModules,
|
||||||
|
eligibility: resolution.entries,
|
||||||
|
startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit
|
||||||
|
* "no modules" statement the required option asks for, never an implicit default. */
|
||||||
|
export function emptyModuleLifecycle(): {
|
||||||
|
readonly snapshot: ModuleLifecycleSnapshot;
|
||||||
|
readonly store: ModuleLifecycleStore;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor([]),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
type ModuleStorage,
|
||||||
|
type ModuleStorageBody,
|
||||||
|
ModuleStorageError,
|
||||||
|
type ModuleStorageListing,
|
||||||
|
type ModuleStorageListOptions,
|
||||||
|
type ModuleStorageObject,
|
||||||
|
type ModuleStorageObjectMetadata,
|
||||||
|
type ModuleStoragePutOptions,
|
||||||
|
type ModuleStorageReadUrl,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import type { DiscoveredModule, ModuleStorageLookup } from "@ekklesia/core/modules";
|
||||||
|
import { createModuleStorageLookup, type StorageProvider } from "@ekklesia/core/storage";
|
||||||
|
|
||||||
|
/** Builds and drains Web streams without depending on DOM-only helper types, which this package's
|
||||||
|
* TypeScript configuration deliberately does not load. */
|
||||||
|
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(bytes);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
total += value.byteLength;
|
||||||
|
}
|
||||||
|
const merged = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
merged.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredObject {
|
||||||
|
readonly bytes: Uint8Array;
|
||||||
|
readonly contentType?: string;
|
||||||
|
readonly lastModified: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory `StorageProvider` for tests that exercise **wiring, scoping, and routing** rather
|
||||||
|
* than the S3 protocol.
|
||||||
|
*
|
||||||
|
* It implements the same interface the real S3 provider does, so everything above the provider seam
|
||||||
|
* — binding resolution, the scoped facade, lifecycle eligibility, route wiring — is exercised
|
||||||
|
* unchanged. What it deliberately does *not* reproduce is the protocol itself: real SeaweedFS
|
||||||
|
* semantics (non-idempotent `CreateBucket`, `NoSuchKey` vs `NoSuchBucket`, ETag quoting, streaming
|
||||||
|
* bodies) are proven against a live server in `core/tests/module-storage.integration.ts`, so this
|
||||||
|
* double can never quietly stand in for a provider guarantee nobody checked.
|
||||||
|
*
|
||||||
|
* It keys everything by **physical** bucket, exactly as the real provider does, which is what lets a
|
||||||
|
* test assert that two modules declaring the same logical name really did land in different
|
||||||
|
* namespaces.
|
||||||
|
*/
|
||||||
|
export function createInMemoryStorageProvider(): StorageProvider & {
|
||||||
|
readonly buckets: ReadonlyMap<string, Map<string, StoredObject>>;
|
||||||
|
ensureBucketCalls: number;
|
||||||
|
} {
|
||||||
|
const buckets = new Map<string, Map<string, StoredObject>>();
|
||||||
|
const state = { ensureBucketCalls: 0 };
|
||||||
|
|
||||||
|
const objectsIn = (physicalBucket: string): Map<string, StoredObject> => {
|
||||||
|
const bucket = buckets.get(physicalBucket);
|
||||||
|
if (!bucket) {
|
||||||
|
throw new ModuleStorageError("STORAGE_BUCKET_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadataFor = (key: string, stored: StoredObject): ModuleStorageObjectMetadata =>
|
||||||
|
Object.freeze({
|
||||||
|
contentLength: stored.bytes.byteLength,
|
||||||
|
key,
|
||||||
|
lastModified: stored.lastModified,
|
||||||
|
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider: StorageProvider = {
|
||||||
|
async close(): Promise<void> {
|
||||||
|
// Nothing to release.
|
||||||
|
},
|
||||||
|
async createReadUrl(
|
||||||
|
physicalBucket: string,
|
||||||
|
key: string,
|
||||||
|
urlOptions: { readonly expiresInSeconds: number; readonly downloadFilename?: string },
|
||||||
|
): Promise<ModuleStorageReadUrl> {
|
||||||
|
// Keyed by physical bucket like everything else here, so a route test can prove the URL a
|
||||||
|
// handler received belongs to that module's namespace and not another's.
|
||||||
|
return Object.freeze({
|
||||||
|
expiresAt: new Date(Date.now() + urlOptions.expiresInSeconds * 1000),
|
||||||
|
url: `https://delivery.test/${physicalBucket}/${encodeURIComponent(key)}?token=fake`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async delete(physicalBucket: string, key: string): Promise<void> {
|
||||||
|
objectsIn(physicalBucket).delete(key);
|
||||||
|
},
|
||||||
|
async ensureBucket(physicalBucket: string): Promise<void> {
|
||||||
|
state.ensureBucketCalls += 1;
|
||||||
|
if (!buckets.has(physicalBucket)) {
|
||||||
|
buckets.set(physicalBucket, new Map());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async get(physicalBucket: string, key: string): Promise<ModuleStorageObject> {
|
||||||
|
const stored = objectsIn(physicalBucket).get(key);
|
||||||
|
if (!stored) {
|
||||||
|
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...metadataFor(key, stored),
|
||||||
|
body: streamOf(stored.bytes),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async head(physicalBucket: string, key: string): Promise<ModuleStorageObjectMetadata> {
|
||||||
|
const stored = objectsIn(physicalBucket).get(key);
|
||||||
|
if (!stored) {
|
||||||
|
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
|
||||||
|
}
|
||||||
|
return metadataFor(key, stored);
|
||||||
|
},
|
||||||
|
async list(
|
||||||
|
physicalBucket: string,
|
||||||
|
options: ModuleStorageListOptions,
|
||||||
|
): Promise<ModuleStorageListing> {
|
||||||
|
const entries = [...objectsIn(physicalBucket).entries()]
|
||||||
|
.filter(([key]) => options.prefix === undefined || key.startsWith(options.prefix))
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
||||||
|
return Object.freeze({
|
||||||
|
objects: Object.freeze(entries.map(([key, stored]) => metadataFor(key, stored))),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async probe(): Promise<void> {
|
||||||
|
// Always reachable.
|
||||||
|
},
|
||||||
|
async put(
|
||||||
|
physicalBucket: string,
|
||||||
|
key: string,
|
||||||
|
body: ModuleStorageBody,
|
||||||
|
options: ModuleStoragePutOptions,
|
||||||
|
): Promise<ModuleStorageObjectMetadata> {
|
||||||
|
const bytes =
|
||||||
|
typeof body === "string"
|
||||||
|
? new TextEncoder().encode(body)
|
||||||
|
: body instanceof Uint8Array
|
||||||
|
? body
|
||||||
|
: await collect(body as ReadableStream<Uint8Array>);
|
||||||
|
const stored: StoredObject = {
|
||||||
|
bytes,
|
||||||
|
lastModified: new Date(0),
|
||||||
|
...(options.contentType === undefined ? {} : { contentType: options.contentType }),
|
||||||
|
};
|
||||||
|
objectsIn(physicalBucket).set(key, stored);
|
||||||
|
return metadataFor(key, stored);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.assign(provider, {
|
||||||
|
buckets: buckets as ReadonlyMap<string, Map<string, StoredObject>>,
|
||||||
|
get ensureBucketCalls(): number {
|
||||||
|
return state.ensureBucketCalls;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleStorageOption {
|
||||||
|
readonly snapshots: ModuleStorageLookup;
|
||||||
|
readonly providerConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the `createApiApp` storage option the same way `startApi` does — resolve every module's
|
||||||
|
* declared buckets against one provider and one prefix — rather than hand-writing a map, so a test
|
||||||
|
* can never assert against a shape the real composition would not produce.
|
||||||
|
*/
|
||||||
|
export function moduleStorageOptionFor(
|
||||||
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
|
provider?: StorageProvider,
|
||||||
|
): ModuleStorageOption {
|
||||||
|
return {
|
||||||
|
providerConfigured: provider !== undefined,
|
||||||
|
snapshots: createModuleStorageLookup(
|
||||||
|
provider,
|
||||||
|
"ekklesia",
|
||||||
|
discoveredModules.map((module) => ({
|
||||||
|
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
|
||||||
|
moduleId: module.manifest.id,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The storage option for a `createApiApp` caller that exercises no storage at all — the explicit
|
||||||
|
* "this installation has no object storage" statement the required option asks for. */
|
||||||
|
export function emptyModuleStorage(): ModuleStorageOption {
|
||||||
|
return { providerConfigured: false, snapshots: new Map<string, ModuleStorage>() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ApiHealthOptions } from "../../src/health-routes.js";
|
||||||
|
|
||||||
|
/** Readiness inputs for a composition whose tests do not exercise health: the database answers and
|
||||||
|
* module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */
|
||||||
|
export function readyHealth(): ApiHealthOptions {
|
||||||
|
return {
|
||||||
|
checkDatabase: async () => undefined,
|
||||||
|
isModuleStartupComplete: () => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "postgresql",
|
||||||
|
schema: [
|
||||||
|
"./src/identity/schema.ts",
|
||||||
|
"./src/authorization/schema.ts",
|
||||||
|
"./src/dashboard/schema.ts",
|
||||||
|
"./src/modules/lifecycle-schema.ts",
|
||||||
|
"./src/modules/configuration-schema.ts",
|
||||||
|
"./src/devices/schema.ts",
|
||||||
|
],
|
||||||
|
out: "./drizzle",
|
||||||
|
migrations: {
|
||||||
|
prefix: "index",
|
||||||
|
},
|
||||||
|
breakpoints: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
CREATE TABLE "ekklesia_core_account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"account_id" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"id_token" text,
|
||||||
|
"access_token_expires_at" timestamp,
|
||||||
|
"refresh_token_expires_at" timestamp,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ekklesia_core_session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
CONSTRAINT "ekklesia_core_session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ekklesia_core_user" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "ekklesia_core_user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ekklesia_core_verification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_account" ADD CONSTRAINT "ekklesia_core_account_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_session" ADD CONSTRAINT "ekklesia_core_session_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_account_user_id_idx" ON "ekklesia_core_account" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_session_user_id_idx" ON "ekklesia_core_session" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_verification_identifier_idx" ON "ekklesia_core_verification" USING btree ("identifier");
|
||||||
@@ -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,362 @@
|
|||||||
|
{
|
||||||
|
"id": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"moduleId": "core",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"tag": "0000_identity_core_tables",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the pinned Better Auth CLI's canonical schema generator for
|
||||||
|
* `pnpm identity:schema:generate` and `pnpm identity:schema:validate`.
|
||||||
|
*
|
||||||
|
* This file is a development-time aid only: it is never imported by application, factory, or
|
||||||
|
* runtime code, never opens a database connection, and intentionally omits our custom table
|
||||||
|
* names so the generator always produces Better Auth's official default (unrenamed) schema for
|
||||||
|
* comparison against core/src/identity/schema.ts. Do not add plugins or providers here.
|
||||||
|
*/
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: drizzleAdapter({} as never, {
|
||||||
|
provider: "pg",
|
||||||
|
}),
|
||||||
|
secret: "0".repeat(32),
|
||||||
|
baseURL: "https://identity-schema-generator.invalid",
|
||||||
|
});
|
||||||
+49
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -15,26 +16,73 @@
|
|||||||
"types": "./dist/modules/index.d.ts",
|
"types": "./dist/modules/index.d.ts",
|
||||||
"import": "./dist/modules/index.js"
|
"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": {
|
"./migrations": {
|
||||||
"types": "./dist/migrations/index.d.ts",
|
"types": "./dist/migrations/index.d.ts",
|
||||||
"import": "./dist/migrations/index.js"
|
"import": "./dist/migrations/index.js"
|
||||||
|
},
|
||||||
|
"./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": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json",
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"identity:migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||||
|
"identity:schema:generate": "auth generate --config=identity-schema-generator.config.ts --output=.identity-schema-reference.ts -y",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "3.1116.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "3.1116.0",
|
||||||
"@ekklesia/contracts": "workspace:*",
|
"@ekklesia/contracts": "workspace:*",
|
||||||
|
"better-auth": "1.6.23",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
|
"nodemailer": "9.0.5",
|
||||||
"pg": "8.22.0"
|
"pg": "8.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
"@types/nodemailer": "8.0.1",
|
||||||
"@types/pg": "8.20.0",
|
"@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",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||||
|
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||||
|
import {
|
||||||
|
ekklesiaCoreAuthorizationAudit,
|
||||||
|
ekklesiaCorePlatformOwner,
|
||||||
|
ekklesiaCorePlatformOwnerBootstrap,
|
||||||
|
PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
export type PlatformOwnerBootstrapErrorCode =
|
||||||
|
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||||
|
| "PLATFORM_OWNER_BOOTSTRAP_FAILED"
|
||||||
|
| "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND";
|
||||||
|
|
||||||
|
const bootstrapErrorMessages: Readonly<Record<PlatformOwnerBootstrapErrorCode, string>> =
|
||||||
|
Object.freeze({
|
||||||
|
PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED:
|
||||||
|
"Platform-owner bootstrap has already been used and cannot be used again.",
|
||||||
|
PLATFORM_OWNER_BOOTSTRAP_FAILED: "Platform-owner bootstrap could not be completed.",
|
||||||
|
PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND: "The target user does not exist.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||||
|
export class PlatformOwnerBootstrapError extends Error {
|
||||||
|
readonly code: PlatformOwnerBootstrapErrorCode;
|
||||||
|
|
||||||
|
constructor(code: PlatformOwnerBootstrapErrorCode) {
|
||||||
|
super(bootstrapErrorMessages[code]);
|
||||||
|
this.name = "PlatformOwnerBootstrapError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlatformOwnerBootstrapError(error: unknown): error is PlatformOwnerBootstrapError {
|
||||||
|
return error instanceof PlatformOwnerBootstrapError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fixed, sanitized, non-user actor identity for the audit trail — distinguishable from both an
|
||||||
|
* ordinary "system" action with no specific actor and from any real `EkklesiaUserId`, so an
|
||||||
|
* auditor can tell this owner grant came specifically from the bootstrap command. Never a real
|
||||||
|
* user id and never derived from caller input. */
|
||||||
|
const BOOTSTRAP_ACTOR_TYPE = "system";
|
||||||
|
const BOOTSTRAP_ACTOR_ID = "authorization-bootstrap-cli";
|
||||||
|
|
||||||
|
// A dedicated advisory-lock key pair, distinct from `core/src/migrations/postgres-database.ts`'s
|
||||||
|
// migration lock, so bootstrap serialization can never collide with or be blocked by unrelated
|
||||||
|
// migration locking. `pg_advisory_xact_lock` is transaction-scoped: it is released automatically
|
||||||
|
// on COMMIT or ROLLBACK, so no manual unlock is needed even if the transaction fails partway.
|
||||||
|
const bootstrapLockNamespace = 1_500_000_001;
|
||||||
|
const bootstrapLockKey = 1_500_000_002;
|
||||||
|
|
||||||
|
export interface BootstrapPlatformOwnerInput {
|
||||||
|
readonly userId: EkklesiaUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
|
||||||
|
* composition of "check, then call the existing grant function": the single-use claim, the target
|
||||||
|
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
|
||||||
|
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
|
||||||
|
* statement — so two concurrent callers can never both observe "not yet consumed" and both insert.
|
||||||
|
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
|
||||||
|
* re-evaluates against the now-current state.
|
||||||
|
*
|
||||||
|
* **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued
|
||||||
|
* existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so
|
||||||
|
* treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's
|
||||||
|
* user row silently reopened the one operation that establishes a platform's first administrator —
|
||||||
|
* along with the audit evidence that it had ever run. The dedicated singleton has no user foreign
|
||||||
|
* key, is never deleted by Core, and therefore survives owner revocation and user deletion alike.
|
||||||
|
*
|
||||||
|
* Consumption is deliberately tied to *success*: the sentinel is written inside the same
|
||||||
|
* transaction as the owner row and its audit entry, so a missing target user, a constraint
|
||||||
|
* violation, or any other failure rolls the sentinel back with everything else and leaves the one
|
||||||
|
* legitimate first use still available. There is no reset command, deletion path, HTTP endpoint,
|
||||||
|
* environment override, or force flag — by design, "consumed" is permanent.
|
||||||
|
*/
|
||||||
|
export async function bootstrapPlatformOwner(
|
||||||
|
db: AuthorizationDrizzleDatabase,
|
||||||
|
input: BootstrapPlatformOwnerInput,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const consumedRows = await tx
|
||||||
|
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||||
|
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||||
|
.limit(1);
|
||||||
|
if (consumedRows.length > 0) {
|
||||||
|
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserRows = await tx
|
||||||
|
.select({ id: ekklesiaCoreUser.id })
|
||||||
|
.from(ekklesiaCoreUser)
|
||||||
|
.where(eq(ekklesiaCoreUser.id, input.userId))
|
||||||
|
.limit(1);
|
||||||
|
if (targetUserRows.length === 0) {
|
||||||
|
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sentinel is claimed inside the same transaction as everything below it. The advisory
|
||||||
|
// lock already serializes concurrent callers; the singleton primary key is the independent,
|
||||||
|
// structural backstop that makes a second row impossible even if a future caller reached
|
||||||
|
// this insert without the lock.
|
||||||
|
await tx.insert(ekklesiaCorePlatformOwnerBootstrap).values({
|
||||||
|
consumedForUserId: input.userId,
|
||||||
|
id: PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.insert(ekklesiaCorePlatformOwner).values({
|
||||||
|
grantedByActorId: BOOTSTRAP_ACTOR_ID,
|
||||||
|
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
|
||||||
|
userId: input.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||||
|
action: "platform_owner.grant",
|
||||||
|
actorId: BOOTSTRAP_ACTOR_ID,
|
||||||
|
actorType: BOOTSTRAP_ACTOR_TYPE,
|
||||||
|
subjectUserId: input.userId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isPlatformOwnerBootstrapError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool, type PoolConfig } from "pg";
|
||||||
|
import {
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
databaseTlsConnectionOptions,
|
||||||
|
} from "../database/transport.js";
|
||||||
|
import { authorizationSchema } from "./schema.js";
|
||||||
|
|
||||||
|
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
|
||||||
|
|
||||||
|
export type AuthorizationDatabaseErrorCode = "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID";
|
||||||
|
|
||||||
|
const maximumPostgresTimeoutMs = 2_147_483_647;
|
||||||
|
|
||||||
|
/** Never carries the offending connection string, host, or underlying driver error. */
|
||||||
|
export class AuthorizationDatabaseError extends Error {
|
||||||
|
readonly code: AuthorizationDatabaseErrorCode;
|
||||||
|
|
||||||
|
constructor(code: AuthorizationDatabaseErrorCode) {
|
||||||
|
super("Authorization database configuration is invalid.");
|
||||||
|
this.name = "AuthorizationDatabaseError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizationDatabaseOptions {
|
||||||
|
readonly connectionTimeoutMs?: number;
|
||||||
|
/** Bounds both `query_timeout` and `statement_timeout`, mirroring
|
||||||
|
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
|
||||||
|
* connection, not a hung query already running on one. */
|
||||||
|
readonly operationTimeoutMs?: number;
|
||||||
|
/** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by
|
||||||
|
* every production executable; omitted by single-domain unit/integration factories that use
|
||||||
|
* the disposable loopback database, which keeps the pre-existing plaintext behavior. */
|
||||||
|
readonly transport?: DatabaseTransportPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizationDatabaseHandle {
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
readonly db: AuthorizationDrizzleDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConnectionString(connectionString: string): string {
|
||||||
|
if (connectionString.trim().length === 0) {
|
||||||
|
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||||
|
}
|
||||||
|
let protocol: string;
|
||||||
|
try {
|
||||||
|
protocol = new URL(connectionString).protocol;
|
||||||
|
} catch {
|
||||||
|
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||||
|
}
|
||||||
|
if (protocol !== "postgres:" && protocol !== "postgresql:") {
|
||||||
|
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||||
|
}
|
||||||
|
return connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors `core/src/identity/database.ts`'s timeout validation exactly: an explicit timeout must
|
||||||
|
* be a positive safe integer no larger than PostgreSQL's own maximum, or it is rejected as invalid
|
||||||
|
* configuration rather than silently passed through to `pg`. */
|
||||||
|
function validateTimeout(value: unknown): number | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value <= 0 ||
|
||||||
|
value > maximumPostgresTimeoutMs
|
||||||
|
) {
|
||||||
|
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly constructs the Core-owned Drizzle database instance backing the authorization store.
|
||||||
|
* Building the `pg.Pool` performs no network I/O until a query is issued, and this function is
|
||||||
|
* only ever invoked by explicit caller code — never during module import.
|
||||||
|
*/
|
||||||
|
export function createAuthorizationDrizzleDatabase(
|
||||||
|
connectionString: string,
|
||||||
|
options: AuthorizationDatabaseOptions = {},
|
||||||
|
): AuthorizationDatabaseHandle {
|
||||||
|
const validatedConnectionString = validateConnectionString(connectionString);
|
||||||
|
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||||
|
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||||
|
const ssl =
|
||||||
|
options.transport === undefined
|
||||||
|
? undefined
|
||||||
|
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
|
||||||
|
const poolConfig: PoolConfig = {
|
||||||
|
connectionString: validatedConnectionString,
|
||||||
|
...(ssl === undefined ? {} : { ssl }),
|
||||||
|
...(validatedConnectionTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||||
|
...(validatedOperationTimeoutMs === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
query_timeout: validatedOperationTimeoutMs,
|
||||||
|
statement_timeout: validatedOperationTimeoutMs,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = new Pool(poolConfig);
|
||||||
|
// Same rationale as core/src/identity/database.ts: pg's own documentation requires an error
|
||||||
|
// listener on the pool, or a server-initiated termination of an idle client becomes an
|
||||||
|
// unhandled Node.js error and can crash the process.
|
||||||
|
pool.on("error", () => undefined);
|
||||||
|
const db = drizzle(pool, { schema: authorizationSchema });
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
close: () => pool.end(),
|
||||||
|
db,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed Drizzle view over an externally owned pool — for example one shared with the identity
|
||||||
|
* schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike
|
||||||
|
* `createAuthorizationDrizzleDatabase`, this function never constructs, registers an error
|
||||||
|
* listener on, or closes a pool itself: the caller retains that responsibility exactly once,
|
||||||
|
* regardless of how many typed views it creates over the same pool.
|
||||||
|
*/
|
||||||
|
export function createAuthorizationDrizzleView(pool: Pool): AuthorizationDrizzleDatabase {
|
||||||
|
return drizzle(pool, { schema: authorizationSchema });
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
AUTHORIZATION_ALLOWED,
|
||||||
|
AUTHORIZATION_DENIED,
|
||||||
|
type AuthorizationDecision,
|
||||||
|
type AuthorizationScope,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
isAuthenticatedPrincipal,
|
||||||
|
isDevicePrincipal,
|
||||||
|
permissionBaselineSchema,
|
||||||
|
safeParseAuthorizationScope,
|
||||||
|
safeParsePermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import type {
|
||||||
|
AuthorizationDevicePort,
|
||||||
|
AuthorizationGrantPort,
|
||||||
|
AuthorizationPlatformOwnerPort,
|
||||||
|
} from "./ports.js";
|
||||||
|
|
||||||
|
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
|
||||||
|
|
||||||
|
/** Never carries the underlying port error, database error, cause, stack, or any other internal
|
||||||
|
* detail — defense in depth on top of whatever sanitization the injected ports themselves already
|
||||||
|
* perform (`core/src/authorization/store.ts` already sanitizes), exactly like
|
||||||
|
* `core/src/identity/runtime.ts`'s `resolvePrincipal` re-sanitizes on top of Better Auth's own
|
||||||
|
* error handling rather than trusting it exclusively. */
|
||||||
|
export class AuthorizationEvaluationError extends Error {
|
||||||
|
readonly code: AuthorizationEvaluationErrorCode;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("The authorization decision could not be evaluated.");
|
||||||
|
this.name = "AuthorizationEvaluationError";
|
||||||
|
this.code = "AUTHORIZATION_EVALUATION_FAILED";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizeRequest {
|
||||||
|
readonly principal: EkklesiaPrincipal;
|
||||||
|
/** Validated internally via the branded `PermissionId` schema *before any baseline can return an
|
||||||
|
* allowed decision* — a malformed or unrecognizable value denies rather than throwing, for
|
||||||
|
* `public` and `authenticated` baselines exactly as much as `restricted`. This is shape
|
||||||
|
* validation only: the evaluator has no registry and cannot tell a well-formed-but-unregistered
|
||||||
|
* permission from one that is actually registered. Only a trusted caller consulting a
|
||||||
|
* `PermissionRegistry` (`core/src/authorization/permissions.ts`) before calling `authorize` can
|
||||||
|
* make that distinction — see that module and `apps/api/src/authorization-middleware.ts`. */
|
||||||
|
readonly permission: unknown;
|
||||||
|
/** Validated internally against the known `public`/`authenticated`/`restricted` set; anything
|
||||||
|
* else denies rather than throwing, for the same fail-closed reason. */
|
||||||
|
readonly baseline: unknown;
|
||||||
|
/** Optional; validated internally when present. Absent means an unscoped check. */
|
||||||
|
readonly scope?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizationEvaluator {
|
||||||
|
authorize(request: AuthorizeRequest): Promise<AuthorizationDecision>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAuthorizationEvaluatorOptions {
|
||||||
|
readonly grants: AuthorizationGrantPort;
|
||||||
|
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
||||||
|
/** Device authority. Optional so every existing caller composes unchanged; when it is absent a
|
||||||
|
* device principal simply has no permissions and is denied, which is the correct answer for a
|
||||||
|
* deployment that has no device support wired at all. */
|
||||||
|
readonly devices?: AuthorizationDevicePort;
|
||||||
|
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
|
||||||
|
* real wall clock; production composition must leave this unset. */
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's Hono-independent, deny-by-default authorization evaluator. It is the only place
|
||||||
|
* Ekklesia decides whether a principal may exercise a permission, and it never trusts anything
|
||||||
|
* the caller supplies beyond `principal`/`permission`/`baseline`/`scope` — there is no field for a
|
||||||
|
* caller to assert "I am the owner" or "I already have this grant." Owner and grant state are
|
||||||
|
* always re-derived from the injected ports for every call.
|
||||||
|
*
|
||||||
|
* A genuine infrastructure failure from either port (for example a database outage) is
|
||||||
|
* deliberately NOT swallowed into a `denied` decision here: it propagates to the caller as a
|
||||||
|
* thrown, sanitized `AuthorizationEvaluationError`, exactly like Core's identity
|
||||||
|
* `resolvePrincipal` never silently downgrades an outage to "anonymous." Only a genuinely
|
||||||
|
* malformed or unrecognized permission/baseline/scope — a deterministic, expected input problem,
|
||||||
|
* not an infrastructure failure — resolves to `denied` without throwing.
|
||||||
|
*/
|
||||||
|
export function createAuthorizationEvaluator(
|
||||||
|
options: CreateAuthorizationEvaluatorOptions,
|
||||||
|
): AuthorizationEvaluator {
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
authorize: async (request: AuthorizeRequest): Promise<AuthorizationDecision> => {
|
||||||
|
const parsedBaseline = permissionBaselineSchema.safeParse(request.baseline);
|
||||||
|
if (!parsedBaseline.success) {
|
||||||
|
return AUTHORIZATION_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shape-validated before any baseline is allowed to return `allowed` — a malformed
|
||||||
|
// permission id must deny under `public` and `authenticated` exactly as much as under
|
||||||
|
// `restricted`, even though neither of the first two ever reads `parsedPermission.data`.
|
||||||
|
// This is a pure, synchronous check: it never touches the grant or platform-owner ports, so
|
||||||
|
// `public`/`authenticated` still resolve with no database lookup at all.
|
||||||
|
const parsedPermission = safeParsePermissionId(request.permission);
|
||||||
|
if (!parsedPermission.success) {
|
||||||
|
return AUTHORIZATION_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedBaseline.data === "public") {
|
||||||
|
return AUTHORIZATION_ALLOWED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedBaseline.data === "authenticated") {
|
||||||
|
// A device deliberately does not satisfy this. The baseline means "a person is signed in",
|
||||||
|
// and every route that settles for it — the module UI inventory, anything a human is
|
||||||
|
// expected to be looking at — would otherwise open to an unattended screen (ADR 0036).
|
||||||
|
return isAuthenticatedPrincipal(request.principal)
|
||||||
|
? AUTHORIZATION_ALLOWED
|
||||||
|
: AUTHORIZATION_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scope: AuthorizationScope | undefined;
|
||||||
|
if (request.scope !== undefined) {
|
||||||
|
const parsedScope = safeParseAuthorizationScope(request.scope);
|
||||||
|
if (!parsedScope.success) {
|
||||||
|
return AUTHORIZATION_DENIED;
|
||||||
|
}
|
||||||
|
scope = parsedScope.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A device satisfies `restricted` only by holding that exact permission, and by no other
|
||||||
|
// route: there is no owner check here, no baseline that stands in for a grant, and no branch
|
||||||
|
// that returns `allowed` before this lookup. "Paired" is not authority — a device that was
|
||||||
|
// paired for one display has nothing else.
|
||||||
|
if (isDevicePrincipal(request.principal)) {
|
||||||
|
if (!options.devices || scope !== undefined) {
|
||||||
|
// A scoped check has no device meaning yet; denying is the honest answer rather than
|
||||||
|
// silently treating it as unscoped.
|
||||||
|
return AUTHORIZATION_DENIED;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await options.devices.hasActivePermission(
|
||||||
|
request.principal.deviceId,
|
||||||
|
parsedPermission.data,
|
||||||
|
))
|
||||||
|
? AUTHORIZATION_ALLOWED
|
||||||
|
: AUTHORIZATION_DENIED;
|
||||||
|
} catch {
|
||||||
|
throw new AuthorizationEvaluationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsedBaseline.data === "restricted": requires an authenticated principal AND
|
||||||
|
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
|
||||||
|
if (!isAuthenticatedPrincipal(request.principal)) {
|
||||||
|
return AUTHORIZATION_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = request.principal.userId;
|
||||||
|
let isOwner: boolean;
|
||||||
|
let hasGrant: boolean;
|
||||||
|
try {
|
||||||
|
[isOwner, hasGrant] = await Promise.all([
|
||||||
|
options.platformOwners.isPlatformOwner(userId),
|
||||||
|
options.grants.hasActiveGrant({
|
||||||
|
now: now(),
|
||||||
|
permission: parsedPermission.data,
|
||||||
|
userId,
|
||||||
|
...(scope === undefined ? {} : { scope }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
throw new AuthorizationEvaluationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return isOwner || hasGrant ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./bootstrap.js";
|
||||||
|
export * from "./database.js";
|
||||||
|
export * from "./evaluator.js";
|
||||||
|
export * from "./initial-owner.js";
|
||||||
|
export * from "./permissions.js";
|
||||||
|
export * from "./ports.js";
|
||||||
|
export * from "./store.js";
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||||
|
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import type { BootstrapIdentityCreator } from "../identity/bootstrap-identity.js";
|
||||||
|
import { BootstrapIdentityError } from "../identity/bootstrap-identity.js";
|
||||||
|
import type { IdentityDrizzleDatabase } from "../identity/database.js";
|
||||||
|
import { ekklesiaCoreAccount, ekklesiaCoreUser } from "../identity/schema.js";
|
||||||
|
import { bootstrapPlatformOwner, PlatformOwnerBootstrapError } from "./bootstrap.js";
|
||||||
|
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||||
|
import { ekklesiaCorePlatformOwnerBootstrap } from "./schema.js";
|
||||||
|
|
||||||
|
export type InitialPlatformOwnerErrorCode =
|
||||||
|
| "INITIAL_OWNER_ALREADY_COMPLETED"
|
||||||
|
| "INITIAL_OWNER_EMAIL_INVALID"
|
||||||
|
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
||||||
|
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
||||||
|
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
|
||||||
|
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
||||||
|
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
||||||
|
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
||||||
|
| "INITIAL_OWNER_SCHEMA_UNAVAILABLE";
|
||||||
|
|
||||||
|
const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, string>> = Object.freeze(
|
||||||
|
{
|
||||||
|
INITIAL_OWNER_ALREADY_COMPLETED:
|
||||||
|
"This installation already has a platform owner. Initial-owner bootstrap runs once and cannot run again.",
|
||||||
|
INITIAL_OWNER_EMAIL_INVALID: "The email address is not a valid address.",
|
||||||
|
INITIAL_OWNER_IDENTITY_NOT_CREATED:
|
||||||
|
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
||||||
|
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
||||||
|
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
||||||
|
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
|
||||||
|
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
|
||||||
|
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
||||||
|
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
||||||
|
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||||
|
INITIAL_OWNER_PASSWORD_TOO_SHORT: "The password is shorter than the identity system allows.",
|
||||||
|
INITIAL_OWNER_SCHEMA_UNAVAILABLE:
|
||||||
|
"The database is unreachable or has not been migrated. Apply migrations before creating the initial owner.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never carries the password, a hash, a connection string, SQL, a driver error, or a cause. The
|
||||||
|
* messages are written to be read by an operator at a terminal, so each one states what happened
|
||||||
|
* *and* what is still true about the installation — in particular whether bootstrap remains
|
||||||
|
* available, which is the only fact that determines what they should do next.
|
||||||
|
*/
|
||||||
|
export class InitialPlatformOwnerError extends Error {
|
||||||
|
readonly code: InitialPlatformOwnerErrorCode;
|
||||||
|
|
||||||
|
constructor(code: InitialPlatformOwnerErrorCode) {
|
||||||
|
super(initialOwnerMessages[code]);
|
||||||
|
this.name = "InitialPlatformOwnerError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CLI-level advisory key distinct from `bootstrap.ts`'s: taking the same one would deadlock this
|
||||||
|
// operation against the grant it performs. This serializes whole invocations; that one serializes
|
||||||
|
// the grant transaction itself, and both remain in force.
|
||||||
|
const initialOwnerLockNamespace = 1_500_000_003;
|
||||||
|
const initialOwnerLockKey = 1_500_000_004;
|
||||||
|
|
||||||
|
export interface CreateInitialPlatformOwnerDependencies {
|
||||||
|
readonly authorizationDb: AuthorizationDrizzleDatabase;
|
||||||
|
readonly identityDb: IdentityDrizzleDatabase;
|
||||||
|
readonly createIdentity: BootstrapIdentityCreator;
|
||||||
|
/** Injected so a partial failure — identity created, ownership not granted — can be exercised.
|
||||||
|
* Production composition leaves it unset and the real single-use bootstrap is used. */
|
||||||
|
readonly grantOwnership?: (userId: EkklesiaUserId) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInitialPlatformOwnerInput {
|
||||||
|
readonly email: string;
|
||||||
|
readonly password: string;
|
||||||
|
/** The display name the identity model requires. */
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitialPlatformOwnerResult {
|
||||||
|
readonly email: string;
|
||||||
|
readonly userId: EkklesiaUserId;
|
||||||
|
/** `created` when this call made the identity; `adopted` when one already existed for that
|
||||||
|
* address and only ownership was granted. Reported so an operator is never told something was
|
||||||
|
* created when it was not. */
|
||||||
|
readonly identity: "adopted" | "created";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExistingIdentity {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly hasCredential: boolean;
|
||||||
|
/** As the identity system actually stored it, which is not necessarily as the operator typed it. */
|
||||||
|
readonly storedEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an identity by email, case-insensitively.
|
||||||
|
*
|
||||||
|
* Deliberately not an exact match on the caller's spelling: Better Auth lowercases the address it
|
||||||
|
* stores (verified against the pinned release — `[email protected]` persists as
|
||||||
|
* `[email protected]`), so an exact comparison would fail to find the very account this operation
|
||||||
|
* had just created and would report it missing.
|
||||||
|
*/
|
||||||
|
async function findIdentityByEmail(
|
||||||
|
db: IdentityDrizzleDatabase,
|
||||||
|
email: string,
|
||||||
|
): Promise<ExistingIdentity | undefined> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ email: ekklesiaCoreUser.email, id: ekklesiaCoreUser.id })
|
||||||
|
.from(ekklesiaCoreUser)
|
||||||
|
.where(sql`lower(${ekklesiaCoreUser.email}) = ${email.trim().toLowerCase()}`)
|
||||||
|
.limit(1);
|
||||||
|
const found = rows[0];
|
||||||
|
if (!found) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// A row in `user` alone is not an account anyone can sign in as — the credential lives in
|
||||||
|
// `account`. The distinction matters: granting ownership to a passwordless row would consume the
|
||||||
|
// one-time bootstrap on an identity nobody can authenticate as, leaving the installation with an
|
||||||
|
// owner that cannot log in and no second chance.
|
||||||
|
const credentials = await db
|
||||||
|
.select({ id: ekklesiaCoreAccount.id })
|
||||||
|
.from(ekklesiaCoreAccount)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreAccount.userId, found.id),
|
||||||
|
eq(ekklesiaCoreAccount.providerId, "credential"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks one identity's address verified.
|
||||||
|
*
|
||||||
|
* A direct column update rather than an identity-system call, because the pinned release exposes no
|
||||||
|
* supported way to mark an address verified other than consuming a token it emailed — which is the
|
||||||
|
* one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the
|
||||||
|
* exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from
|
||||||
|
* one that verified normally; nothing here invents a parallel notion of verification.
|
||||||
|
*/
|
||||||
|
async function markIdentityEmailVerified(
|
||||||
|
db: IdentityDrizzleDatabase,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(ekklesiaCoreUser)
|
||||||
|
.set({ emailVerified: true })
|
||||||
|
.where(eq(ekklesiaCoreUser.id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
|
||||||
|
if (error instanceof BootstrapIdentityError) {
|
||||||
|
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
||||||
|
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_SHORT");
|
||||||
|
}
|
||||||
|
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG") {
|
||||||
|
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_LONG");
|
||||||
|
}
|
||||||
|
if (error.code === "BOOTSTRAP_IDENTITY_EMAIL_INVALID") {
|
||||||
|
return new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the first platform owner of a fresh installation: one identity, then the single-use
|
||||||
|
* ownership grant.
|
||||||
|
*
|
||||||
|
* ## Why the identity is verified rather than trusted
|
||||||
|
*
|
||||||
|
* Better Auth's sign-up returns a **fabricated success** when the address is already registered —
|
||||||
|
* a user object carrying an id that was never persisted, with the database left unchanged (verified
|
||||||
|
* against the pinned release). On a public endpoint that is correct: it refuses to tell an
|
||||||
|
* anonymous caller which addresses exist. Here it is a trap, because a caller that believed the
|
||||||
|
* response would report an owner it never created and then grant ownership to an id that does not
|
||||||
|
* exist. So the identity is always read back from the database, and that read — not the sign-up
|
||||||
|
* response — is what the grant uses.
|
||||||
|
*
|
||||||
|
* ## Why an existing identity is adopted rather than refused or overwritten
|
||||||
|
*
|
||||||
|
* If the address already has a usable account and bootstrap is still open, ownership is granted to
|
||||||
|
* it and **its password is left completely alone**. This is precisely the recovery path for the one
|
||||||
|
* partial failure this operation can have: sign-up commits through Better Auth's own connection and
|
||||||
|
* the grant runs in a separate transaction, so a crash between them leaves an identity with no
|
||||||
|
* ownership. Refusing would strand a fresh installation with no way forward but manual SQL; a
|
||||||
|
* password reset would be a silent takeover of an account the operator may not own. Adoption
|
||||||
|
* changes no credential, and it is reported as `adopted` so nobody is told an account was created
|
||||||
|
* when it was not.
|
||||||
|
*
|
||||||
|
* That is safe here specifically because production has **no** other way to create an identity —
|
||||||
|
* public signup cannot be enabled at all — so an account present on a fresh installation was put
|
||||||
|
* there by someone who already had database access.
|
||||||
|
*
|
||||||
|
* ## Ordering
|
||||||
|
*
|
||||||
|
* The bootstrap sentinel is checked before an identity is created, so a closed bootstrap never
|
||||||
|
* leaves a stray account behind. That check is an early exit, not the authority: `bootstrapPlatformOwner`
|
||||||
|
* re-checks it inside its own locked transaction, which is what actually makes the grant single-use.
|
||||||
|
*/
|
||||||
|
export async function createInitialPlatformOwner(
|
||||||
|
dependencies: CreateInitialPlatformOwnerDependencies,
|
||||||
|
input: CreateInitialPlatformOwnerInput,
|
||||||
|
): Promise<InitialPlatformOwnerResult> {
|
||||||
|
const email = input.email.trim();
|
||||||
|
if (email.length === 0) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serializes entire invocations against each other. Without it two operators racing on a fresh
|
||||||
|
// installation would both pass the sentinel check and both create an identity, and only then
|
||||||
|
// would one lose the grant — leaving a stray account that can sign in and owns nothing. The grant
|
||||||
|
// alone was already safe; this makes the whole command safe.
|
||||||
|
return dependencies.authorizationDb.transaction(async (lockTx) => {
|
||||||
|
await lockTx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(${initialOwnerLockNamespace}, ${initialOwnerLockKey})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let alreadyConsumed: boolean;
|
||||||
|
try {
|
||||||
|
const consumed = await lockTx
|
||||||
|
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||||
|
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||||
|
.limit(1);
|
||||||
|
alreadyConsumed = consumed.length > 0;
|
||||||
|
} catch {
|
||||||
|
// An unreachable database and a database without the authorization tables are the same
|
||||||
|
// situation to an operator running this on a fresh server: the deployment step before this
|
||||||
|
// one has not happened. Never migrated implicitly — see ADR 0035.
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
if (alreadyConsumed) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: ExistingIdentity | undefined;
|
||||||
|
try {
|
||||||
|
existing = await findIdentityByEmail(dependencies.identityDb, email);
|
||||||
|
} catch {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
if (existing && !existing.hasCredential) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_UNUSABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captured before the read-back below, which cannot distinguish the two: an identity this call
|
||||||
|
// just created and one it adopted both come back with a credential.
|
||||||
|
const identity: "adopted" | "created" = existing ? "adopted" : "created";
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
try {
|
||||||
|
await dependencies.createIdentity({
|
||||||
|
email,
|
||||||
|
name: input.name,
|
||||||
|
password: input.password,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw translateIdentityCreationError(error);
|
||||||
|
}
|
||||||
|
existing = await findIdentityByEmail(dependencies.identityDb, email).catch(() => undefined);
|
||||||
|
if (!existing) {
|
||||||
|
// Reached when sign-up reported success without persisting anything.
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUserId = safeParseEkklesiaUserId(existing.userId);
|
||||||
|
if (!parsedUserId.success) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trusted-bootstrap verification exception (ADR 0038).
|
||||||
|
*
|
||||||
|
* Every other human account must verify its address by email before it may hold a session.
|
||||||
|
* This one cannot: it is created on a freshly provisioned server by an operator who already
|
||||||
|
* holds the database credentials, at a moment when SMTP may not be configured, may not be
|
||||||
|
* reachable, or may not yet have a DNS record to send from. Requiring an email round trip here
|
||||||
|
* would mean a working installation whose administrator can never get in — the platform's
|
||||||
|
* outbound mail would have to be configured and correct before anyone could sign in to
|
||||||
|
* configure anything.
|
||||||
|
*
|
||||||
|
* The exception is narrow and its authority is the same authority that already makes this
|
||||||
|
* command safe: possession of the database, not possession of the mailbox. It marks exactly one
|
||||||
|
* address, exactly once per installation, and it is deliberately performed **before** ownership
|
||||||
|
* is granted — if it fails, the sentinel and the grant roll back together and re-running the
|
||||||
|
* command adopts the identity and finishes, rather than leaving an owner who cannot sign in.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data);
|
||||||
|
} catch {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const grant =
|
||||||
|
dependencies.grantOwnership ??
|
||||||
|
((userId: EkklesiaUserId) =>
|
||||||
|
bootstrapPlatformOwner(dependencies.authorizationDb, { userId }));
|
||||||
|
await grant(parsedUserId.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof PlatformOwnerBootstrapError &&
|
||||||
|
error.code === "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||||
|
) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||||
|
}
|
||||||
|
// The identity is real and remains usable; the sentinel rolled back with the grant, so
|
||||||
|
// re-running this command with the same address adopts it and finishes.
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_OWNERSHIP_GRANT_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored address, never the operator's spelling: the identity system lowercases what it
|
||||||
|
// persists, and telling someone to sign in as `[email protected]` when the account is
|
||||||
|
// `[email protected]` invites them to conclude the command failed.
|
||||||
|
return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import {
|
||||||
|
type PermissionDeclaration,
|
||||||
|
type PermissionId,
|
||||||
|
parsePermissionId,
|
||||||
|
safeParsePermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's registry consumes the one shared `PermissionDeclaration` shape
|
||||||
|
* (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module
|
||||||
|
* manifest's `permissions` array parses into the exact same type, so a declaration a module ships
|
||||||
|
* and a declaration Core registers are provably interchangeable. Re-exported here so existing
|
||||||
|
* `@ekklesia/core/authorization` consumers keep importing it from this package.
|
||||||
|
*/
|
||||||
|
export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export interface PermissionRegistry {
|
||||||
|
readonly declarations: readonly PermissionDeclaration[];
|
||||||
|
/** Returns the declaration for a permission id, or `undefined` for anything malformed or not
|
||||||
|
* registered — the two are deliberately indistinguishable to callers, so "unknown permission"
|
||||||
|
* and "invalid permission" fail closed the same way. */
|
||||||
|
get(permissionId: unknown): PermissionDeclaration | undefined;
|
||||||
|
has(permissionId: unknown): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionRegistryErrorCode = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
|
||||||
|
|
||||||
|
/** Thrown only at composition time (registry construction), never per-request. Carries the
|
||||||
|
* offending permission id deliberately — this is a startup-time programmer/configuration error
|
||||||
|
* message, not a runtime response, and a namespaced permission id is not sensitive. */
|
||||||
|
export class PermissionRegistryError extends Error {
|
||||||
|
readonly code: PermissionRegistryErrorCode;
|
||||||
|
readonly permissionId: string;
|
||||||
|
|
||||||
|
constructor(permissionId: string) {
|
||||||
|
super(`Permission "${permissionId}" is already registered.`);
|
||||||
|
this.name = "PermissionRegistryError";
|
||||||
|
this.code = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
|
||||||
|
this.permissionId = permissionId;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an immutable permission registry from a fixed list of declarations, rejecting any
|
||||||
|
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that
|
||||||
|
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
|
||||||
|
* network or filesystem I/O. The module integration layer's composer
|
||||||
|
* (`composePlatformPermissionRegistry`, `core/src/modules/permissions.ts`) merges Core's own
|
||||||
|
* declarations with every discovered module's into one combined list and calls this same
|
||||||
|
* constructor, inheriting duplicate detection for free rather than reimplementing it.
|
||||||
|
*/
|
||||||
|
export function createPermissionRegistry(
|
||||||
|
declarations: readonly PermissionDeclaration[],
|
||||||
|
): PermissionRegistry {
|
||||||
|
const byId = new Map<PermissionId, PermissionDeclaration>();
|
||||||
|
for (const declaration of declarations) {
|
||||||
|
if (byId.has(declaration.id)) {
|
||||||
|
throw new PermissionRegistryError(declaration.id);
|
||||||
|
}
|
||||||
|
byId.set(declaration.id, Object.freeze({ ...declaration }));
|
||||||
|
}
|
||||||
|
const frozenDeclarations = Object.freeze([...byId.values()]);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
declarations: frozenDeclarations,
|
||||||
|
get(permissionId: unknown): PermissionDeclaration | undefined {
|
||||||
|
const parsed = safeParsePermissionId(permissionId);
|
||||||
|
return parsed.success ? byId.get(parsed.data) : undefined;
|
||||||
|
},
|
||||||
|
has(permissionId: unknown): boolean {
|
||||||
|
const parsed = safeParsePermissionId(permissionId);
|
||||||
|
return parsed.success && byId.has(parsed.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restricted: delegated grant administration is itself a privileged action, never public or
|
||||||
|
* merely authenticated. A platform owner always passes the evaluator's existing owner bypass and
|
||||||
|
* can delegate this permission to another account deliberately via the explicit-grant API. */
|
||||||
|
export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
||||||
|
"core.authorization.grants.manage",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated: any signed-in user of this installation may learn which module UIs the platform is
|
||||||
|
* currently hosting, because that is exactly what a browser shell has to render before it can offer
|
||||||
|
* anything at all. It is deliberately not `public` — the anonymous artifact route (ADR 0025) is
|
||||||
|
* careful never to become an enumeration oracle, and an anonymous inventory would hand over the very
|
||||||
|
* listing that route withholds. It is deliberately not `restricted` either: a shell that rendered
|
||||||
|
* nothing until an operator granted every user this permission would make the platform unusable for
|
||||||
|
* its ordinary users, and the module UIs themselves already gate their own content on their own
|
||||||
|
* permissions.
|
||||||
|
*
|
||||||
|
* Listing a module UI is not permission to use that module. Every module route remains enforced
|
||||||
|
* against its own declared permission, unchanged.
|
||||||
|
*/
|
||||||
|
export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricted: reorganizing the Dashboard is an administrative action, not something every signed-in
|
||||||
|
* user may do. It is deliberately a **platform** permission and never a module's own — grouping is
|
||||||
|
* Ekklesia's presentation configuration, so no module may gate, grant, or influence it (ADR 0027).
|
||||||
|
*
|
||||||
|
* Holding it confers no module authority whatsoever. Moving a module into a folder changes a
|
||||||
|
* presentation row; it grants nobody access to that module's data, and every module route stays
|
||||||
|
* enforced against its own declared permission exactly as before.
|
||||||
|
*/
|
||||||
|
export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId(
|
||||||
|
"core.dashboard.navigation.manage",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricted, and it gates **reading as well as changing**. Deciding which installed modules run is
|
||||||
|
* an administrative operation, and the read is not a milder version of it: the lifecycle view is the
|
||||||
|
* only place a *disabled* module, a *blocked* module, and the required-dependency edges between them
|
||||||
|
* are visible at all. `core.modules.ui.read` deliberately exposes the mountable set to every signed-in
|
||||||
|
* user; this exposes the parts of the installation that are deliberately not running, which is not
|
||||||
|
* the same audience. A second, weaker read permission over the same resource would be ceremony
|
||||||
|
* (ADR 0027's reasoning, applied in the other direction).
|
||||||
|
*
|
||||||
|
* It is a **platform** permission and never a module's. A module can neither declare it, gate it, nor
|
||||||
|
* observe it: whether a module runs is a decision its host makes about it, not a decision it takes
|
||||||
|
* part in (ADR 0028). Holding it confers no authority inside any module — every module route stays
|
||||||
|
* enforced against that module's own declared permission.
|
||||||
|
*/
|
||||||
|
export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId(
|
||||||
|
"core.modules.lifecycle.manage",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize
|
||||||
|
* genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides
|
||||||
|
* *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted
|
||||||
|
* to take modules offline, and the reverse is equally true, so folding them into one permission
|
||||||
|
* would grant authority nobody asked for in whichever direction it was used (ADR 0029).
|
||||||
|
*
|
||||||
|
* It gates reading as well as writing, because the read returns the module's declared schema
|
||||||
|
* together with every stored value — endpoints, bucket names, and whatever else an operator has
|
||||||
|
* configured. That is deployment detail, not something every signed-in user needs.
|
||||||
|
*
|
||||||
|
* **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never
|
||||||
|
* persisted, and is never returned by any route; the API reports only whether one is configured.
|
||||||
|
* Holding this permission therefore confers no ability to read a credential — there is no code path
|
||||||
|
* that would serve one.
|
||||||
|
*
|
||||||
|
* Like every other Core permission it is a **platform** permission a module can neither declare,
|
||||||
|
* gate, nor observe, and holding it confers no authority inside any module.
|
||||||
|
*/
|
||||||
|
export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId(
|
||||||
|
"core.modules.configuration.manage",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricted, and deliberately a **platform** permission rather than a module's.
|
||||||
|
*
|
||||||
|
* A device is an identity the platform issues, so deciding that a screen in the foyer may exist —
|
||||||
|
* approving its pairing, seeing what has been paired, and taking it away again — is authority over
|
||||||
|
* Ekklesia itself, not over the module whose display happens to be shown. Letting a module declare
|
||||||
|
* or gate it would mean whoever administers a slideshow could mint credentials that outlive it.
|
||||||
|
*
|
||||||
|
* It gates approval, listing, and revocation together. Splitting "see the paired devices" from
|
||||||
|
* "revoke one" would be ceremony: the same person is trusted with both, and a reader who cannot act
|
||||||
|
* on what they see is not a role anyone has asked for (ADR 0027's reasoning, applied again).
|
||||||
|
*
|
||||||
|
* Holding it confers **no** authority inside any module and no ability to read a device credential
|
||||||
|
* — none is stored in a recoverable form, so there is no code path that could serve one (ADR 0036).
|
||||||
|
*/
|
||||||
|
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||||
|
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||||
|
* exactly these plus every discovered module's declarations, in a fixed, deterministic order,
|
||||||
|
* without reconstructing or guessing Core's own list. `CORE_PERMISSION_REGISTRY` below remains the
|
||||||
|
* registry Core-only code and existing tests use directly.
|
||||||
|
*/
|
||||||
|
export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||||
|
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "authenticated" as const,
|
||||||
|
description: "List the installed module UIs a browser host may mount.",
|
||||||
|
id: CORE_MODULES_UI_READ,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
||||||
|
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Read and change which installed modules are enabled.",
|
||||||
|
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Read and change an installed module's own configuration values.",
|
||||||
|
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||||
|
id: CORE_DEVICES_MANAGE,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's own permission registry, containing exactly the Core-owned permissions declared above.
|
||||||
|
* Production `apps/api` does not consult this constant directly: it enforces requests against the
|
||||||
|
* composed platform registry (`composePlatformPermissionRegistry`,
|
||||||
|
* `core/src/modules/permissions.ts`), which merges these same declarations with every installed
|
||||||
|
* module's. `CORE_PERMISSION_REGISTRY` remains for Core-only callers and tests that need Core's
|
||||||
|
* permissions in isolation, without composing in any module.
|
||||||
|
*/
|
||||||
|
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
|
||||||
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type {
|
||||||
|
AuthorizationScope,
|
||||||
|
EkklesiaDeviceId,
|
||||||
|
EkklesiaUserId,
|
||||||
|
PermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export interface ActiveGrantQuery {
|
||||||
|
readonly userId: EkklesiaUserId;
|
||||||
|
readonly permission: PermissionId;
|
||||||
|
/** Absent means an unscoped check. An unscoped grant satisfies either an unscoped or scoped
|
||||||
|
* check for the same permission; a scoped grant satisfies only the exact matching scope and
|
||||||
|
* never satisfies an unscoped check. */
|
||||||
|
readonly scope?: AuthorizationScope;
|
||||||
|
/** The instant to evaluate expiration against — injected so evaluation is deterministic and
|
||||||
|
* testable rather than reading the wall clock inside the lookup itself. */
|
||||||
|
readonly now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only read dependency `createAuthorizationEvaluator` has for grant state. Narrow and
|
||||||
|
* Postgres-independent by design: a test can supply a fake implementation, and the evaluator
|
||||||
|
* itself never knows whether it is backed by `core/src/authorization/store.ts` or a test double.
|
||||||
|
*/
|
||||||
|
export interface AuthorizationGrantPort {
|
||||||
|
hasActiveGrant(query: ActiveGrantQuery): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The only read dependency the evaluator has for platform-owner authority — deliberately a
|
||||||
|
* separate, narrower port from grants, since owner authority is not represented as a grant. */
|
||||||
|
export interface AuthorizationPlatformOwnerPort {
|
||||||
|
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The evaluator's only read dependency for **device** authority.
|
||||||
|
*
|
||||||
|
* Separate from `AuthorizationGrantPort` because the two answer questions about different kinds of
|
||||||
|
* subject, not because devices are a special case in the decision: `authorize` consults exactly one
|
||||||
|
* of them per call, by principal kind, and neither can be reached without going through it.
|
||||||
|
*
|
||||||
|
* There is deliberately no device counterpart to `AuthorizationPlatformOwnerPort`. A device is
|
||||||
|
* never a platform owner, so there is no port that could accidentally say it was.
|
||||||
|
*/
|
||||||
|
export interface AuthorizationDevicePort {
|
||||||
|
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { relations, sql } from "drizzle-orm";
|
||||||
|
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core-owned authorization schema: grants (explicit or relationship-derived), platform-owner
|
||||||
|
* status, and an append-only audit log of every security-relevant mutation. Table and index names
|
||||||
|
* are explicit and namespaced so ownership is unambiguous inside the shared public PostgreSQL
|
||||||
|
* schema, exactly like `core/src/identity/schema.ts`. Foreign keys to `ekklesia_core_user` are
|
||||||
|
* safe here (unlike a module referencing another module's tables) because Core owns both sides.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ekklesiaCoreGrant = pgTable(
|
||||||
|
"ekklesia_core_grant",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||||
|
permissionId: text("permission_id").notNull(),
|
||||||
|
scope: text("scope"),
|
||||||
|
sourceKind: text("source_kind").notNull(),
|
||||||
|
sourceRef: text("source_ref"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
createdByActorType: text("created_by_actor_type").notNull(),
|
||||||
|
createdByActorId: text("created_by_actor_id"),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||||
|
revokedByActorType: text("revoked_by_actor_type"),
|
||||||
|
revokedByActorId: text("revoked_by_actor_id"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ekklesia_core_grant_user_permission_idx").on(table.userId, table.permissionId),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_grant_source_kind_check",
|
||||||
|
sql`${table.sourceKind} in ('explicit', 'relationship')`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_grant_created_actor_type_check",
|
||||||
|
sql`${table.createdByActorType} in ('user', 'system')`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_grant_revoked_actor_type_check",
|
||||||
|
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ekklesiaCorePlatformOwner = pgTable(
|
||||||
|
"ekklesia_core_platform_owner",
|
||||||
|
{
|
||||||
|
userId: text("user_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||||
|
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
grantedByActorType: text("granted_by_actor_type").notNull(),
|
||||||
|
grantedByActorId: text("granted_by_actor_id"),
|
||||||
|
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||||
|
revokedByActorType: text("revoked_by_actor_type"),
|
||||||
|
revokedByActorId: text("revoked_by_actor_id"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
"ekklesia_core_platform_owner_granted_actor_type_check",
|
||||||
|
sql`${table.grantedByActorType} in ('user', 'system')`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||||
|
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only *by application convention*: Core's authorization store exposes no update or delete
|
||||||
|
* path for these rows. That is a property of this codebase, not a database guarantee — no
|
||||||
|
* PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or
|
||||||
|
* anything else holding the same credentials from modifying them. `scope` intentionally rejects an
|
||||||
|
* empty (but non-null) string — defense in depth matching the application-layer
|
||||||
|
* `AuthorizationScope` contract, which never produces one — so a coding defect that slipped an
|
||||||
|
* empty scope past that validation fails the whole grant-plus-audit transaction atomically instead
|
||||||
|
* of recording a meaningless audit entry.
|
||||||
|
*
|
||||||
|
* `subject_user_id` is a **retained historical snapshot**, deliberately carrying no foreign key to
|
||||||
|
* the live Better Auth user row. It previously cascaded on user deletion, which meant deleting a
|
||||||
|
* user destroyed exactly the security history describing what that user had been granted and who
|
||||||
|
* granted it — the audit trail disappeared precisely when it mattered most. The value stays a
|
||||||
|
* plain, non-null identifier string; no name, email, or other profile data is ever copied here.
|
||||||
|
*/
|
||||||
|
export const ekklesiaCoreAuthorizationAudit = pgTable(
|
||||||
|
"ekklesia_core_authorization_audit",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
action: text("action").notNull(),
|
||||||
|
actorType: text("actor_type").notNull(),
|
||||||
|
actorId: text("actor_id"),
|
||||||
|
subjectUserId: text("subject_user_id").notNull(),
|
||||||
|
permissionId: text("permission_id"),
|
||||||
|
scope: text("scope"),
|
||||||
|
grantId: uuid("grant_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ekklesia_core_authorization_audit_subject_idx").on(table.subjectUserId),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_authorization_audit_action_check",
|
||||||
|
sql`${table.action} in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_authorization_audit_actor_type_check",
|
||||||
|
sql`${table.actorType} in ('user', 'system')`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_authorization_audit_scope_check",
|
||||||
|
sql`${table.scope} is null or length(${table.scope}) > 0`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The durable, single-row record meaning "platform-owner bootstrap was successfully consumed."
|
||||||
|
*
|
||||||
|
* Bootstrap's single-use authority previously came from the mere existence of a row in
|
||||||
|
* `ekklesia_core_platform_owner` — which cascades on user deletion, so deleting the bootstrapped
|
||||||
|
* owner's user row silently reopened the one-time operation that establishes the platform's first
|
||||||
|
* administrator. This table exists specifically so that authority survives owner revocation and
|
||||||
|
* user deletion: it has **no foreign key to any user**, and nothing in Core ever deletes from it.
|
||||||
|
*
|
||||||
|
* `id` is pinned by a check constraint to the single literal value `'singleton'`, so the primary
|
||||||
|
* key makes a second row structurally impossible rather than merely unwritten by convention —
|
||||||
|
* concurrent first attempts collide on the primary key inside their transactions.
|
||||||
|
*
|
||||||
|
* `consumed_for_user_id` is an optional historical snapshot of who bootstrap made an owner, in the
|
||||||
|
* same retained-identifier style as the audit subject and carrying no profile data. It is null for
|
||||||
|
* an installation whose consumed state was backfilled by migration, because a backfill cannot know
|
||||||
|
* which of several historical owner rows the original single use actually targeted, and guessing
|
||||||
|
* one would fabricate history.
|
||||||
|
*/
|
||||||
|
export const ekklesiaCorePlatformOwnerBootstrap = pgTable(
|
||||||
|
"ekklesia_core_platform_owner_bootstrap",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
consumedAt: timestamp("consumed_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
consumedForUserId: text("consumed_for_user_id"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check("ekklesia_core_platform_owner_bootstrap_singleton_check", sql`${table.id} = 'singleton'`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The only value `ekklesia_core_platform_owner_bootstrap.id` may ever hold. */
|
||||||
|
export const PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID = "singleton";
|
||||||
|
|
||||||
|
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
|
||||||
|
user: one(ekklesiaCoreUser, {
|
||||||
|
fields: [ekklesiaCoreGrant.userId],
|
||||||
|
references: [ekklesiaCoreUser.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ekklesiaCorePlatformOwnerRelations = relations(
|
||||||
|
ekklesiaCorePlatformOwner,
|
||||||
|
({ one }) => ({
|
||||||
|
user: one(ekklesiaCoreUser, {
|
||||||
|
fields: [ekklesiaCorePlatformOwner.userId],
|
||||||
|
references: [ekklesiaCoreUser.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table:
|
||||||
|
// its subject is a retained historical identifier that may name a user row that no longer exists,
|
||||||
|
// so modelling it as a live relation would misdescribe the data.
|
||||||
|
|
||||||
|
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
|
||||||
|
export const authorizationSchema = {
|
||||||
|
authorizationAudit: ekklesiaCoreAuthorizationAudit,
|
||||||
|
grant: ekklesiaCoreGrant,
|
||||||
|
platformOwner: ekklesiaCorePlatformOwner,
|
||||||
|
platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
import type {
|
||||||
|
AuthorizationActor,
|
||||||
|
AuthorizationScope,
|
||||||
|
EkklesiaUserId,
|
||||||
|
GrantId,
|
||||||
|
GrantSourceKind,
|
||||||
|
GrantSourceRef,
|
||||||
|
PermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
isUserActor,
|
||||||
|
parseAuthorizationScope,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
parseGrantId,
|
||||||
|
parsePermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
|
||||||
|
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||||
|
import type {
|
||||||
|
ActiveGrantQuery,
|
||||||
|
AuthorizationGrantPort,
|
||||||
|
AuthorizationPlatformOwnerPort,
|
||||||
|
} from "./ports.js";
|
||||||
|
import {
|
||||||
|
ekklesiaCoreAuthorizationAudit,
|
||||||
|
ekklesiaCoreGrant,
|
||||||
|
ekklesiaCorePlatformOwner,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
export type AuthorizationStoreErrorCode =
|
||||||
|
| "AUTHORIZATION_GRANT_ALREADY_EXISTS"
|
||||||
|
| "AUTHORIZATION_GRANT_CREATE_FAILED"
|
||||||
|
| "AUTHORIZATION_GRANT_EXPIRATION_INVALID"
|
||||||
|
| "AUTHORIZATION_GRANT_LIMIT_INVALID"
|
||||||
|
| "AUTHORIZATION_GRANT_LIST_FAILED"
|
||||||
|
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
|
||||||
|
| "AUTHORIZATION_GRANT_NOT_FOUND"
|
||||||
|
| "AUTHORIZATION_GRANT_REVOKE_FAILED"
|
||||||
|
| "AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED"
|
||||||
|
| "AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED"
|
||||||
|
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
|
||||||
|
|
||||||
|
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
||||||
|
AUTHORIZATION_GRANT_ALREADY_EXISTS:
|
||||||
|
"An equivalent authorization grant is already effective for this user.",
|
||||||
|
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
||||||
|
AUTHORIZATION_GRANT_EXPIRATION_INVALID:
|
||||||
|
"The authorization grant expiration must be a valid instant in the future.",
|
||||||
|
AUTHORIZATION_GRANT_LIMIT_INVALID: "The authorization grant page size is invalid.",
|
||||||
|
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
|
||||||
|
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
|
||||||
|
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
|
||||||
|
AUTHORIZATION_GRANT_REVOKE_FAILED: "The authorization grant could not be revoked.",
|
||||||
|
AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED: "The platform-owner lookup could not be completed.",
|
||||||
|
AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED: "The platform-owner change could not be recorded.",
|
||||||
|
AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND:
|
||||||
|
"The platform-owner record was not found or is already revoked.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The largest page any caller may request from `listGrantsForUser`, enforced identically by the
|
||||||
|
* store and by the HTTP surface so there is exactly one bound, not two that could drift. */
|
||||||
|
export const AUTHORIZATION_GRANT_LIST_MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||||
|
export class AuthorizationStoreError extends Error {
|
||||||
|
readonly code: AuthorizationStoreErrorCode;
|
||||||
|
|
||||||
|
constructor(code: AuthorizationStoreErrorCode) {
|
||||||
|
super(storeErrorMessages[code]);
|
||||||
|
this.name = "AuthorizationStoreError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorizationStoreError(error: unknown): error is AuthorizationStoreError {
|
||||||
|
return error instanceof AuthorizationStoreError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGrantInput {
|
||||||
|
readonly userId: EkklesiaUserId;
|
||||||
|
readonly permission: PermissionId;
|
||||||
|
readonly scope?: AuthorizationScope;
|
||||||
|
readonly sourceKind: GrantSourceKind;
|
||||||
|
readonly sourceRef?: GrantSourceRef;
|
||||||
|
readonly actor: AuthorizationActor;
|
||||||
|
/** Absent means the grant never expires. */
|
||||||
|
readonly expiresAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrantRecord {
|
||||||
|
readonly id: GrantId;
|
||||||
|
readonly userId: EkklesiaUserId;
|
||||||
|
readonly permission: PermissionId;
|
||||||
|
readonly scope?: AuthorizationScope;
|
||||||
|
readonly sourceKind: GrantSourceKind;
|
||||||
|
readonly sourceRef?: GrantSourceRef;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly expiresAt?: Date;
|
||||||
|
readonly revokedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevokeGrantInput {
|
||||||
|
readonly grantId: GrantId;
|
||||||
|
readonly actor: AuthorizationActor;
|
||||||
|
/** When provided, the revoke only matches a grant whose `sourceKind` equals this value — a
|
||||||
|
* grant of a different source kind is treated exactly like a nonexistent one
|
||||||
|
* (`AUTHORIZATION_GRANT_NOT_FOUND`). The generic explicit-grant HTTP API always passes
|
||||||
|
* `"explicit"` here, so it can never revoke a relationship-derived grant regardless of caller
|
||||||
|
* intent; internal Core callers that omit this may revoke any grant, matching pass 1's
|
||||||
|
* original, unconstrained behavior. */
|
||||||
|
readonly sourceKind?: GrantSourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row's position in the `created_at DESC, id DESC` ordering, used as a strict keyset
|
||||||
|
* boundary. Both values are required: comparing on the timestamp alone would skip or repeat rows
|
||||||
|
* whenever two grants share a `created_at`, which concurrent creation makes routine. */
|
||||||
|
export interface GrantPageBoundary {
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly id: GrantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListGrantsForUserOptions {
|
||||||
|
/** Required. The maximum number of rows to return, 1 through
|
||||||
|
* `AUTHORIZATION_GRANT_LIST_MAX_LIMIT`. There is deliberately no default and no unbounded
|
||||||
|
* fallback anywhere: an unbounded listing returned a user's entire authorization history in one
|
||||||
|
* response, and a bound a caller may simply omit is not a bound. */
|
||||||
|
readonly limit: number;
|
||||||
|
/** When provided, only grants strictly *after* this position in the ordering are returned. */
|
||||||
|
readonly after?: GrantPageBoundary;
|
||||||
|
/** When provided, only grants with this exact `sourceKind` are returned. */
|
||||||
|
readonly sourceKind?: GrantSourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrantPage {
|
||||||
|
readonly grants: readonly GrantRecord[];
|
||||||
|
/** The last returned row's position when more rows exist, otherwise `undefined`. */
|
||||||
|
readonly nextBoundary?: GrantPageBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAuthorizationStoreOptions {
|
||||||
|
/** Injected only so expiration validation is deterministic under test. Production composition
|
||||||
|
* leaves this unset and the store reads real time. */
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
|
||||||
|
* append-only audit log, all behind one explicit factory — no environment reads, no connection
|
||||||
|
* attempt, and no query merely from importing this module or calling this factory; every method
|
||||||
|
* call is the only thing that ever touches PostgreSQL. Implements the narrow read ports
|
||||||
|
* `createAuthorizationEvaluator` depends on directly, so the same store instance is both Core's
|
||||||
|
* write API and the evaluator's read dependency.
|
||||||
|
*/
|
||||||
|
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
|
||||||
|
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
|
||||||
|
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
||||||
|
/** One bounded page of the user's grants, active or revoked, ordered `created_at DESC, id DESC`.
|
||||||
|
* Never includes another user's grants. */
|
||||||
|
listGrantsForUser(userId: EkklesiaUserId, options: ListGrantsForUserOptions): Promise<GrantPage>;
|
||||||
|
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||||
|
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actorId(actor: AuthorizationActor): string | null {
|
||||||
|
return isUserActor(actor) ? actor.userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GrantRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly userId: string;
|
||||||
|
readonly permissionId: string;
|
||||||
|
readonly scope: string | null;
|
||||||
|
readonly sourceKind: string;
|
||||||
|
readonly sourceRef: string | null;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly expiresAt: Date | null;
|
||||||
|
readonly revokedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-validates a row this same store just wrote, rather than trusting raw column values by
|
||||||
|
* construction — defense in depth against a future schema/migration drift, matching the
|
||||||
|
* paranoid-by-default posture the rest of Core's persistence code already follows. */
|
||||||
|
function toGrantRecord(row: GrantRow): GrantRecord {
|
||||||
|
return Object.freeze({
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
id: parseGrantId(row.id),
|
||||||
|
permission: parsePermissionId(row.permissionId),
|
||||||
|
...(row.scope === null ? {} : { scope: parseAuthorizationScope(row.scope) }),
|
||||||
|
...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }),
|
||||||
|
...(row.revokedAt === null ? {} : { revokedAt: row.revokedAt }),
|
||||||
|
sourceKind: row.sourceKind as GrantSourceKind,
|
||||||
|
...(row.sourceRef === null ? {} : { sourceRef: row.sourceRef }),
|
||||||
|
userId: parseEkklesiaUserId(row.userId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five values that make two grant issuances the *same authorization source*: subject,
|
||||||
|
* permission, scope, source kind, and source reference — with `NULL` equal only to `NULL`.
|
||||||
|
* `expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
|
||||||
|
* distinguish them: re-issuing the identical authority with a different expiry or by a different
|
||||||
|
* administrator is the same grant, not a second one.
|
||||||
|
*/
|
||||||
|
interface GrantEquivalence {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly permissionId: string;
|
||||||
|
readonly scope: string | null;
|
||||||
|
readonly sourceKind: string;
|
||||||
|
readonly sourceRef: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function equivalenceOf(input: CreateGrantInput): GrantEquivalence {
|
||||||
|
return {
|
||||||
|
permissionId: input.permission,
|
||||||
|
scope: input.scope ?? null,
|
||||||
|
sourceKind: input.sourceKind,
|
||||||
|
sourceRef: input.sourceRef ?? null,
|
||||||
|
userId: input.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes every create/revoke touching one equivalence tuple against a transaction-scoped
|
||||||
|
* PostgreSQL advisory lock, so concurrent callers cannot both observe "no effective equivalent"
|
||||||
|
* and both insert. A process-local mutex would not do this — nothing constrains the number of API
|
||||||
|
* processes — and neither would an unlocked check-then-insert.
|
||||||
|
*
|
||||||
|
* The key is a 64-bit hash of the tuple encoded as a JSON array, which keeps `NULL` and the empty
|
||||||
|
* string distinct and makes separator forgery through a crafted scope or source reference
|
||||||
|
* impossible. A hash collision is harmless by construction: it can only make two unrelated tuples
|
||||||
|
* serialize against each other, never make a real conflict go unnoticed, because the authoritative
|
||||||
|
* decision is always the exact SQL predicate below and never the hash.
|
||||||
|
*
|
||||||
|
* `pg_advisory_xact_lock(bigint)` uses PostgreSQL's *single-key* advisory-lock space, which is
|
||||||
|
* verified to be disjoint from the two-key space used by the migration runner
|
||||||
|
* (`core/src/migrations/postgres-database.ts`) and by platform-owner bootstrap. Grant serialization
|
||||||
|
* therefore can never block, or be blocked by, migration or bootstrap locking. Being
|
||||||
|
* transaction-scoped, it is released on COMMIT or ROLLBACK with no manual unlock.
|
||||||
|
*/
|
||||||
|
async function lockEquivalence(
|
||||||
|
tx: Parameters<Parameters<AuthorizationDrizzleDatabase["transaction"]>[0]>[0],
|
||||||
|
equivalence: GrantEquivalence,
|
||||||
|
): Promise<void> {
|
||||||
|
await tx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
|
||||||
|
${equivalence.userId}::text,
|
||||||
|
${equivalence.permissionId}::text,
|
||||||
|
${equivalence.scope}::text,
|
||||||
|
${equivalence.sourceKind}::text,
|
||||||
|
${equivalence.sourceRef}::text
|
||||||
|
)::text, 0))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches exactly the rows sharing an equivalence tuple. `is not distinct from` is what gives
|
||||||
|
* `NULL = NULL` for the two nullable members; plain `=` would silently match nothing. */
|
||||||
|
function equivalenceCondition(equivalence: GrantEquivalence) {
|
||||||
|
return and(
|
||||||
|
eq(ekklesiaCoreGrant.userId, equivalence.userId),
|
||||||
|
eq(ekklesiaCoreGrant.permissionId, equivalence.permissionId),
|
||||||
|
sql`${ekklesiaCoreGrant.scope} is not distinct from ${equivalence.scope}::text`,
|
||||||
|
eq(ekklesiaCoreGrant.sourceKind, equivalence.sourceKind),
|
||||||
|
sql`${ekklesiaCoreGrant.sourceRef} is not distinct from ${equivalence.sourceRef}::text`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A supplied expiration must be a real instant strictly later than the acceptance clock. A past
|
||||||
|
* or equal-to-now expiration produces a grant that is dead the moment it is written, which is
|
||||||
|
* never what a caller meant; rejecting it here means trusted internal callers get the same
|
||||||
|
* guarantee the HTTP surface enforces, rather than each validating separately. */
|
||||||
|
function assertValidExpiration(expiresAt: Date | undefined, now: Date): void {
|
||||||
|
if (expiresAt === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(expiresAt instanceof Date) ||
|
||||||
|
Number.isNaN(expiresAt.getTime()) ||
|
||||||
|
expiresAt.getTime() <= now.getTime()
|
||||||
|
) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_EXPIRATION_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidLimit(limit: number): number {
|
||||||
|
if (
|
||||||
|
typeof limit !== "number" ||
|
||||||
|
!Number.isSafeInteger(limit) ||
|
||||||
|
limit < 1 ||
|
||||||
|
limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT
|
||||||
|
) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIMIT_INVALID");
|
||||||
|
}
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthorizationStore(
|
||||||
|
db: AuthorizationDrizzleDatabase,
|
||||||
|
options: CreateAuthorizationStoreOptions = {},
|
||||||
|
): AuthorizationStore {
|
||||||
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
|
||||||
|
// Validated before any transaction opens, so a rejected expiration performs no database work
|
||||||
|
// at all and can never leave a grant or an audit row behind.
|
||||||
|
assertValidExpiration(input.expiresAt, now());
|
||||||
|
const equivalence = equivalenceOf(input);
|
||||||
|
try {
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
await lockEquivalence(tx, equivalence);
|
||||||
|
|
||||||
|
// Under the lock, "currently effective" is authoritative: an equivalent that is revoked,
|
||||||
|
// or whose expiry has already passed, does not block a fresh issuance.
|
||||||
|
const effective = await tx
|
||||||
|
.select({ id: ekklesiaCoreGrant.id })
|
||||||
|
.from(ekklesiaCoreGrant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
equivalenceCondition(equivalence),
|
||||||
|
isNull(ekklesiaCoreGrant.revokedAt),
|
||||||
|
or(
|
||||||
|
isNull(ekklesiaCoreGrant.expiresAt),
|
||||||
|
gt(ekklesiaCoreGrant.expiresAt, sql`now()`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (effective.length > 0) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(ekklesiaCoreGrant)
|
||||||
|
.values({
|
||||||
|
createdByActorId: actorId(input.actor),
|
||||||
|
createdByActorType: input.actor.type,
|
||||||
|
expiresAt: input.expiresAt ?? null,
|
||||||
|
permissionId: input.permission,
|
||||||
|
scope: input.scope ?? null,
|
||||||
|
sourceKind: input.sourceKind,
|
||||||
|
sourceRef: input.sourceRef ?? null,
|
||||||
|
userId: input.userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("grant insert returned no row");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||||
|
action: "grant.create",
|
||||||
|
actorId: actorId(input.actor),
|
||||||
|
actorType: input.actor.type,
|
||||||
|
grantId: row.id,
|
||||||
|
permissionId: input.permission,
|
||||||
|
scope: input.scope ?? null,
|
||||||
|
subjectUserId: input.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toGrantRecord(row);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthorizationStoreError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoking one grant revokes *every* currently unrevoked issuance sharing its equivalence
|
||||||
|
* tuple, in one transaction, with one `grant.revoke` audit row per grant actually changed.
|
||||||
|
*
|
||||||
|
* Addressing a single id and revoking only that row was actively misleading: an operator who
|
||||||
|
* revoked a grant received `success` while an equivalent duplicate kept authorizing the same
|
||||||
|
* user. Expired-but-unrevoked equivalents are included deliberately — they are already
|
||||||
|
* ineffective, but leaving them unrevoked would keep the history in two states for what is one
|
||||||
|
* authorization source. Grants with a different subject, permission, scope, source kind, or
|
||||||
|
* source reference are untouched.
|
||||||
|
*/
|
||||||
|
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// This first read exists only to discover the equivalence tuple, which is safe to read
|
||||||
|
// before the lock because those columns are immutable once written. It deliberately does
|
||||||
|
// *not* settle whether the revocation may proceed: everything it observed can change
|
||||||
|
// while this transaction waits for the lock below.
|
||||||
|
const [target] = await tx
|
||||||
|
.select({
|
||||||
|
permissionId: ekklesiaCoreGrant.permissionId,
|
||||||
|
scope: ekklesiaCoreGrant.scope,
|
||||||
|
sourceKind: ekklesiaCoreGrant.sourceKind,
|
||||||
|
sourceRef: ekklesiaCoreGrant.sourceRef,
|
||||||
|
userId: ekklesiaCoreGrant.userId,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreGrant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||||
|
isNull(ekklesiaCoreGrant.revokedAt),
|
||||||
|
...(input.sourceKind === undefined
|
||||||
|
? []
|
||||||
|
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!target) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
await lockEquivalence(tx, target);
|
||||||
|
|
||||||
|
// Revalidate the *addressed* grant now that the lock is held. Without this, a revocation
|
||||||
|
// that paused between the read above and the lock could wake after its target was
|
||||||
|
// revoked and a fresh equivalent created, and the tuple-matching update below would
|
||||||
|
// silently revoke that replacement — reporting success for a grant the caller never
|
||||||
|
// addressed, in an order no serial execution could produce, and losing the not-found
|
||||||
|
// result an already-revoked target must return.
|
||||||
|
//
|
||||||
|
// The check is deliberately narrow and re-asserts every condition the pre-lock read
|
||||||
|
// relied on: the exact addressed id, still unrevoked, still satisfying the caller's
|
||||||
|
// explicit/relationship source guard, and still carrying the same equivalence tuple the
|
||||||
|
// lock was taken for. It is a plain read, not `for update`: the tuple advisory lock
|
||||||
|
// remains the one create/revoke serialization order, so no second lock ordering — and no
|
||||||
|
// deadlock path — is introduced.
|
||||||
|
const [addressed] = await tx
|
||||||
|
.select({ id: ekklesiaCoreGrant.id })
|
||||||
|
.from(ekklesiaCoreGrant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||||
|
isNull(ekklesiaCoreGrant.revokedAt),
|
||||||
|
...(input.sourceKind === undefined
|
||||||
|
? []
|
||||||
|
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||||
|
equivalenceCondition(target),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!addressed) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only now is the whole equivalence class cleared, which is what still removes genuine
|
||||||
|
// legacy duplicates — including expired-but-unrevoked ones. Narrowing this update to the
|
||||||
|
// addressed id instead would not have been a fix; it would have dropped that behavior.
|
||||||
|
const rows = await tx
|
||||||
|
.update(ekklesiaCoreGrant)
|
||||||
|
.set({
|
||||||
|
revokedAt: sql`now()`,
|
||||||
|
revokedByActorId: actorId(input.actor),
|
||||||
|
revokedByActorType: input.actor.type,
|
||||||
|
})
|
||||||
|
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
|
||||||
|
.returning();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
// Unreachable while the lock is held, since the row confirmed above satisfies this
|
||||||
|
// same predicate — kept as a fail-closed guard against a writer that bypasses the
|
||||||
|
// lock, rather than silently auditing nothing.
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(ekklesiaCoreAuthorizationAudit).values(
|
||||||
|
rows.map((row) => ({
|
||||||
|
action: "grant.revoke",
|
||||||
|
actorId: actorId(input.actor),
|
||||||
|
actorType: input.actor.type,
|
||||||
|
grantId: row.id,
|
||||||
|
permissionId: row.permissionId,
|
||||||
|
scope: row.scope,
|
||||||
|
subjectUserId: row.userId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthorizationStoreError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hasActiveGrant: async (query: ActiveGrantQuery): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const scopeCondition =
|
||||||
|
query.scope === undefined
|
||||||
|
? isNull(ekklesiaCoreGrant.scope)
|
||||||
|
: or(isNull(ekklesiaCoreGrant.scope), eq(ekklesiaCoreGrant.scope, query.scope));
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: ekklesiaCoreGrant.id })
|
||||||
|
.from(ekklesiaCoreGrant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreGrant.userId, query.userId),
|
||||||
|
eq(ekklesiaCoreGrant.permissionId, query.permission),
|
||||||
|
scopeCondition,
|
||||||
|
isNull(ekklesiaCoreGrant.revokedAt),
|
||||||
|
or(isNull(ekklesiaCoreGrant.expiresAt), gt(ekklesiaCoreGrant.expiresAt, query.now)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
} catch {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LOOKUP_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic keyset pagination over `created_at DESC, id DESC`. The boundary is a strict
|
||||||
|
* row-value comparison on *both* columns, so grants sharing a `created_at` — which concurrent
|
||||||
|
* creation makes routine — can neither be skipped nor repeated across pages. Offset pagination
|
||||||
|
* would do exactly that under concurrent writes, which is why it is not used.
|
||||||
|
*
|
||||||
|
* At most `limit + 1` rows are read: the extra row only answers "is there another page" and is
|
||||||
|
* never returned.
|
||||||
|
*/
|
||||||
|
listGrantsForUser: async (
|
||||||
|
userId: EkklesiaUserId,
|
||||||
|
listOptions: ListGrantsForUserOptions,
|
||||||
|
): Promise<GrantPage> => {
|
||||||
|
const limit = assertValidLimit(listOptions.limit);
|
||||||
|
const after = listOptions.after;
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(ekklesiaCoreGrant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreGrant.userId, userId),
|
||||||
|
...(listOptions.sourceKind === undefined
|
||||||
|
? []
|
||||||
|
: [eq(ekklesiaCoreGrant.sourceKind, listOptions.sourceKind)]),
|
||||||
|
...(after === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
sql`(${ekklesiaCoreGrant.createdAt}, ${ekklesiaCoreGrant.id}) < (${after.createdAt}::timestamptz, ${after.id}::uuid)`,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(ekklesiaCoreGrant.createdAt), desc(ekklesiaCoreGrant.id))
|
||||||
|
.limit(limit + 1);
|
||||||
|
|
||||||
|
const page = rows.slice(0, limit).map(toGrantRecord);
|
||||||
|
const last = page.at(-1);
|
||||||
|
return Object.freeze({
|
||||||
|
grants: Object.freeze(page),
|
||||||
|
...(rows.length > limit && last !== undefined
|
||||||
|
? { nextBoundary: Object.freeze({ createdAt: last.createdAt, id: last.id }) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthorizationStoreError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
grantPlatformOwner: async (
|
||||||
|
userId: EkklesiaUserId,
|
||||||
|
actor: AuthorizationActor,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.insert(ekklesiaCorePlatformOwner)
|
||||||
|
.values({
|
||||||
|
grantedByActorId: actorId(actor),
|
||||||
|
grantedByActorType: actor.type,
|
||||||
|
userId,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
set: {
|
||||||
|
grantedAt: sql`now()`,
|
||||||
|
grantedByActorId: actorId(actor),
|
||||||
|
grantedByActorType: actor.type,
|
||||||
|
revokedAt: null,
|
||||||
|
revokedByActorId: null,
|
||||||
|
revokedByActorType: null,
|
||||||
|
},
|
||||||
|
target: ekklesiaCorePlatformOwner.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||||
|
action: "platform_owner.grant",
|
||||||
|
actorId: actorId(actor),
|
||||||
|
actorType: actor.type,
|
||||||
|
subjectUserId: userId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
revokePlatformOwner: async (
|
||||||
|
userId: EkklesiaUserId,
|
||||||
|
actor: AuthorizationActor,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [row] = await tx
|
||||||
|
.update(ekklesiaCorePlatformOwner)
|
||||||
|
.set({
|
||||||
|
revokedAt: sql`now()`,
|
||||||
|
revokedByActorId: actorId(actor),
|
||||||
|
revokedByActorType: actor.type,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCorePlatformOwner.userId, userId),
|
||||||
|
isNull(ekklesiaCorePlatformOwner.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (!row) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||||
|
action: "platform_owner.revoke",
|
||||||
|
actorId: actorId(actor),
|
||||||
|
actorType: actor.type,
|
||||||
|
subjectUserId: userId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthorizationStoreError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isPlatformOwner: async (userId: EkklesiaUserId): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({ userId: ekklesiaCorePlatformOwner.userId })
|
||||||
|
.from(ekklesiaCorePlatformOwner)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCorePlatformOwner.userId, userId),
|
||||||
|
isNull(ekklesiaCorePlatformOwner.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
} catch {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user