Compare commits
79
Commits
5d75d910fd
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2efb4b029 | ||
|
|
f63f80f2f0 | ||
|
|
cbf5962d04 | ||
|
|
38cf52757d | ||
|
|
5162b4cc77 | ||
|
|
8ea4d4d2e4 | ||
|
|
55accd0e65 | ||
|
|
a56a853319 | ||
|
|
b642d8fe30 | ||
|
|
278e66049f | ||
|
|
c737ebb472 | ||
|
|
7cbe4e1b2c | ||
|
|
481e80118b | ||
|
|
167b4c4f14 | ||
|
|
b593abe7a5 | ||
|
|
e12ef10a65 | ||
|
|
4468335e8f | ||
|
|
756e9b821c | ||
|
|
ef1383d080 | ||
|
|
fc422b6d8d | ||
|
|
d5d4928b9f | ||
|
|
a12cbe9600 | ||
|
|
220b7b152a | ||
|
|
aaec9789c4 | ||
|
|
d593c10f1d | ||
|
|
06e55cebd5 | ||
|
|
f3cd5a4152 | ||
|
|
3a26ef49ad | ||
|
|
3ca6b2cb32 | ||
|
|
75324af045 | ||
|
|
f445a83ea5 | ||
|
|
a130eb78ea | ||
|
|
8127cc87b4 | ||
|
|
2969ed4992 | ||
|
|
14ac46bd3d | ||
|
|
bb7a9eb351 | ||
|
|
20d5128bad | ||
|
|
ca3f37d5f9 | ||
|
|
e36485bf99 | ||
|
|
c444ff570b | ||
|
|
07835b7b56 | ||
|
|
e03d38fcab | ||
|
|
b14613decd | ||
|
|
3563549e86 | ||
|
|
450d65116f | ||
|
|
ea28f8aa2e | ||
|
|
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 |
@@ -0,0 +1,25 @@
|
|||||||
|
# Build context for deploy/Dockerfile (ADR 0044). The image installs its own dependencies with
|
||||||
|
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
|
||||||
|
# host may enter the context, and no secret or environment file may enter any layer.
|
||||||
|
|
||||||
|
# Host-installed dependencies and build outputs: the image produces its own.
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/coverage
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment and secret material. Configuration reaches the container only through its runtime
|
||||||
|
# environment; `.env.example` is excluded too, since nothing in the image reads it.
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
|
||||||
|
# Repository and tooling metadata the image never reads.
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitea
|
||||||
|
.forgejo
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
**/*.log
|
||||||
|
**/.DS_Store
|
||||||
+90
-1
@@ -1,4 +1,8 @@
|
|||||||
# 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
|
# 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
|
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
|
||||||
@@ -12,10 +16,95 @@ 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.
|
||||||
|
|||||||
@@ -103,3 +103,69 @@ jobs:
|
|||||||
|
|
||||||
- name: Exercise transactional migrations against PostgreSQL
|
- name: Exercise transactional migrations against PostgreSQL
|
||||||
run: pnpm test:migrations:integration
|
run: pnpm test:migrations:integration
|
||||||
|
|
||||||
|
image-build:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
||||||
|
# has no buildx plugin.
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
|
||||||
|
# builds, validates, and assembles with zero modules. Build only — no registry login, no
|
||||||
|
# credentials, no push, no tag anywhere but this runner's local image store.
|
||||||
|
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
|
||||||
|
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
|
||||||
|
- name: Build the production image without modules
|
||||||
|
run: >-
|
||||||
|
docker build
|
||||||
|
--file deploy/Dockerfile
|
||||||
|
--build-arg REVISION="$GITHUB_SHA"
|
||||||
|
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Remove the image from the runner
|
||||||
|
if: always()
|
||||||
|
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
|
||||||
|
|
||||||
|
deploy-smoke:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which
|
||||||
|
# adds a test-only module and drives a real headless browser across the two origins.
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
topology:
|
||||||
|
- same-origin
|
||||||
|
- split
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
|
||||||
|
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
||||||
|
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
|
||||||
|
# database TLS through the proxy. Every secret is generated for this run and discarded with
|
||||||
|
# it; nothing is pushed, published, or logged in to.
|
||||||
|
- name: Smoke-test the reference deployment (${{ matrix.topology }})
|
||||||
|
run: bash scripts/smoke-deploy.sh
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ monorepo:
|
|||||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||||
surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled
|
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||||
`entrypoints.ui` artifact, authored in React per ADR 0022 against `@ekklesia/module-ui-sdk`. No
|
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||||
host mounts that entrypoint yet, and `pnpm boundaries` still rejects every non-React UI framework
|
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||||
and non-`.tsx` presentation source inside a module package.
|
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||||
- **Surfaces** (future `surfaces/*`) own the containing application shell, navigation, and every
|
- **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
|
presentation concern a module does not. They consume public API/client contracts only, never Core
|
||||||
or module internals directly.
|
or module internals directly.
|
||||||
|
|
||||||
|
|||||||
+42
-9
@@ -9,7 +9,8 @@ Before opening a change, run:
|
|||||||
pnpm verify
|
pnpm verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Migration-runner changes also require the disposable live PostgreSQL suite:
|
Migration-runner changes and changes to identity/authentication or the API's session transport also
|
||||||
|
require the disposable live PostgreSQL suite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm test:migrations:integration
|
pnpm test:migrations:integration
|
||||||
@@ -34,17 +35,49 @@ pnpm infrastructure:config
|
|||||||
private source paths.
|
private source paths.
|
||||||
- Use `workspace:` ranges for internal package dependencies.
|
- Use `workspace:` ranges for internal package dependencies.
|
||||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||||
`pnpm boundaries`. UI source lives at `src/ui/index.tsx` and `pnpm build` compiles it to the
|
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
|
||||||
artifact the manifest declares, via `@ekklesia/module-ui-build` (ADR 0023) — module authors write
|
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||||
no bundler configuration. Styling is React inline style objects only for now, and no host mounts a
|
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
|
||||||
UI entrypoint yet.
|
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
|
||||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk`, and asks the host for its own
|
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
|
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||||
private deployment detail and would break standalone hosting.
|
private deployment detail and would break standalone hosting.
|
||||||
- The containing application shell, navigation, global theme, and platform policy belong to Core and
|
- The containing application shell, navigation, global theme, and platform policy belong to Core and
|
||||||
the host application, never to a module.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,22 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas
|
|||||||
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
|
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
|
||||||
against an independent security audit) is complete: all five passes have landed and the final
|
against an independent security audit) is complete: all five passes have landed and the final
|
||||||
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
||||||
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** The operational
|
not-yet-deployed threat model.** Phase 6 completed through 6D, and **Phase 7A passes 1 (the minimal
|
||||||
|
operational module lifecycle), 2 (the
|
||||||
|
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
|
||||||
|
browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
|
||||||
|
kiosk host) have landed. Phase 7B pass 1 adds the Dashboard's host-owned browser sign-in, sign-out,
|
||||||
|
session-expiry handling, and authenticated-shell transition.** The operational
|
||||||
requirements that remain are prerequisites for two specific *future* activities — executing
|
requirements that remain are prerequisites for two specific *future* activities — executing
|
||||||
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
untrusted CI contributions, and a public production launch — and neither gates ongoing product
|
||||||
|
development; see the
|
||||||
pass 5 closure below and
|
pass 5 closure below and
|
||||||
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes
|
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes
|
||||||
public module contracts, independently versioned capabilities, static module manifests,
|
public module contracts, independently versioned capabilities, static module manifests,
|
||||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||||||
schema aggregation, and a cloneable backend-only module template. Migration artifacts have
|
schema aggregation, and a cloneable module-product template with optional primary and display UI.
|
||||||
|
Migration artifacts have
|
||||||
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
|
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
|
||||||
declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
|
declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
|
||||||
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
|
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
|
||||||
@@ -45,10 +52,13 @@ order never grants elevation — but is now **disabled by default, and cannot be
|
|||||||
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
|
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
|
||||||
[API](docs/development/api.md) and
|
[API](docs/development/api.md) and
|
||||||
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
||||||
**No email verification, password recovery, or Dashboard exists yet** — that, along with
|
Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
|
||||||
production approval-provider composition, jobs, physical storage provisioning, surfaces, and
|
enrollment still do not.
|
||||||
feature modules, remains later vertical slices. Public registration is not yet ready for a real
|
Public registration remains disabled by default and prohibited in production — and a fresh
|
||||||
public launch.
|
production installation now has a supported way to get its first administrator anyway: Phase 7B
|
||||||
|
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
|
||||||
|
but not a finished product UI or a production asset-hosting deployment; jobs, production
|
||||||
|
approval-provider composition, feature modules, and the public Website remain later slices.
|
||||||
|
|
||||||
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
|
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
|
||||||
for platform owners, explicit grants, and relationship-derived grants; a smallest-possible
|
for platform owners, explicit grants, and relationship-derived grants; a smallest-possible
|
||||||
@@ -61,7 +71,7 @@ accounts/API tokens remain later work. See [Authorization](docs/development/auth
|
|||||||
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
||||||
|
|
||||||
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
|
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
|
||||||
(version 3 at the time, version 4 today) declares permissions as complete
|
(version 3 at the time, version 6 today) declares permissions as complete
|
||||||
`{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
|
`{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
|
||||||
permission registry — Core's own declarations plus every module's — before constructing its
|
permission registry — Core's own declarations plus every module's — before constructing its
|
||||||
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
|
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
|
||||||
@@ -230,15 +240,15 @@ never a dependency. The long-term goal is one module implementation hosted by Ek
|
|||||||
by a standalone application — never two codebases for one domain. See
|
by a standalone application — never two codebases for one domain. See
|
||||||
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
|
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
|
||||||
|
|
||||||
**What that pass shipped is the static contract only.** `manifestVersion: 4` adds one
|
**What that pass shipped at its checkpoint was the static contract only.** `manifestVersion: 4`
|
||||||
optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
|
added one optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
|
||||||
during discovery exactly like `server` and `schema` — inside the package, real file, no source
|
during discovery exactly like `server` and `schema` — inside the package, real file, no source
|
||||||
TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly
|
TypeScript. Discovery stayed inert: **nothing imported, mounted, or rendered that entrypoint**,
|
||||||
like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a
|
exactly like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest was a
|
||||||
one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was
|
one-character edit; version 3 was rejected outright rather than reinterpreted, as version 2 was
|
||||||
before it. **The Dashboard shell, a UI runtime or renderer, navigation
|
before it. The Dashboard shell, runtime mounting, and navigation named as absent at that checkpoint
|
||||||
registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading,
|
landed in subsequent Phase 6 passes. A standalone host, module installer or marketplace, hot-loading,
|
||||||
and every feature module do not exist.** Restart-based discovery, explicit operator-invoked
|
and every real feature module still do not exist. Restart-based discovery, explicit operator-invoked
|
||||||
migrations, and the trusted compiled-module model are unchanged.
|
migrations, and the trusted compiled-module model are unchanged.
|
||||||
|
|
||||||
Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
|
Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
|
||||||
@@ -252,8 +262,9 @@ returns an unmount function. A host needs an element and a context, never a matc
|
|||||||
|
|
||||||
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
|
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
|
||||||
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
|
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
|
||||||
host context is deliberately four members — `moduleId`, `api`, `principal`, `permissions` — and
|
host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and
|
||||||
nothing else. **A module UI asks its host for its own API by relative path** (`api.request("posts")`)
|
`realtime` — and nothing else. **A module UI asks its host for its own API by relative path**
|
||||||
|
(`api.request("posts")`)
|
||||||
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
|
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
|
||||||
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
|
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
|
||||||
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
|
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
|
||||||
@@ -278,7 +289,8 @@ artifacts cannot drift.
|
|||||||
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
||||||
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
||||||
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
||||||
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
|
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
|
||||||
|
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
|
||||||
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||||||
and installing a module or starting the API never triggers one.
|
and installing a module or starting the API never triggers one.
|
||||||
|
|
||||||
@@ -287,9 +299,491 @@ from source, validate it with `parseModuleUiExport`, mount it into a real DOM, a
|
|||||||
output, and unmount it — so a UI that cannot be produced fails CI, and no generated file is ever
|
output, and unmount it — so a UI that cannot be produced fails CI, and no generated file is ever
|
||||||
committed. See [ADR 0023](docs/architecture/decisions/0023-module-ui-build-pipeline.md).
|
committed. See [ADR 0023](docs/architecture/decisions/0023-module-ui-build-pipeline.md).
|
||||||
|
|
||||||
**Still no host.** A module UI can be authored, built, and tested; **nothing serves one to a user
|
Phase 6B pass 3 closes the last two foundational gaps. **The browser boundary now actually holds**:
|
||||||
yet.** The Dashboard shell, navigation, a design system, a stylesheet loading contract, and the
|
`@ekklesia/module-ui-sdk` had one type-only import of `@ekklesia/contracts`, whose barrel pulled
|
||||||
standalone host do not exist.
|
Drizzle's types — and with them Node's globals — into every module UI program, so `process.env`
|
||||||
|
typechecked inside browser-only code. The SDK now has **no dependencies at all**, and owns a plain
|
||||||
|
browser `ModuleUiPrincipal` instead of Core's Zod-branded server type; a compile test proves that
|
||||||
|
supported UI source compiles and that `process` does not. **And there is now a generic host loader**:
|
||||||
|
`@ekklesia/module-ui-host` imports a published artifact from a browser URL, validates its id and
|
||||||
|
version against the manifest *before* mounting, mounts it into a host-owned container, and returns an
|
||||||
|
idempotent `unmount()`. A module may never depend on it — it is host infrastructure, and the boundary
|
||||||
|
checker enforces that. See
|
||||||
|
[ADR 0024](docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md).
|
||||||
|
|
||||||
|
Phase 6C pass 1 builds the publication layer those passes were missing. Core resolves
|
||||||
|
`entrypoints.ui` to a path on the *server's filesystem*, and a browser needs a **URL**; the platform
|
||||||
|
now serves one:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/module-ui/<module-id>/<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
`:moduleId` and `:version` are **lookup keys, not path components**. They are matched against a
|
||||||
|
publication registry Core builds from the discovered, started module inventory, and only an exact
|
||||||
|
match causes the already-canonical artifact path discovery validated to be read. No request value is
|
||||||
|
ever joined into a filesystem path and the route declares no wildcard segment, so traversal, encoded
|
||||||
|
separators, sibling chunks, package-root files, and **source maps** are unreachable by construction —
|
||||||
|
**an installed module directory never becomes a static file root**, and only the single artifact its
|
||||||
|
manifest declares is served. A module that is not installed, one that declares no UI, and a wrong
|
||||||
|
version return one identical `404`; an artifact that vanished after discovery returns a sanitized
|
||||||
|
`503` carrying no path. The prefix is a *sibling* of `/api/v1/modules/`, not a child, because `ui` is
|
||||||
|
a legal module route segment and a module could otherwise collide with the platform.
|
||||||
|
|
||||||
|
**The route requires no authentication, deliberately.** Fetching the bundle confers no authority —
|
||||||
|
every module API stays behind `requirePermission`, and the host, not the artifact, supplies the
|
||||||
|
principal — while requiring a cookie would break cross-origin `import()`, which sends none. The cost
|
||||||
|
is stated plainly: a caller who already knows a module id *and* its exact installed version can
|
||||||
|
download that bundle. Caching is `no-cache` with a strong `ETag` rather than `immutable`, because
|
||||||
|
without an installer nothing stops a version being rebuilt with different bytes. See
|
||||||
|
[ADR 0025](docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md).
|
||||||
|
|
||||||
|
Phase 6C pass 2 connects the chain: **Ekklesia now has a browser host that mounts module-owned UI.**
|
||||||
|
`surfaces/shell` (`@ekklesia/shell`) is the repository's first surface — a small React application,
|
||||||
|
built to static files with esbuild — and it does the whole loop at run time:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/principal → who is viewing
|
||||||
|
GET /api/v1/module-ui → which module UIs may be mounted, and where
|
||||||
|
↓ server-produced artifact URL
|
||||||
|
@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container
|
||||||
|
↓
|
||||||
|
context.api.request("records") → /api/v1/modules/example/records
|
||||||
|
context.realtime.subscribe("state") → host-owned live state transport
|
||||||
|
```
|
||||||
|
|
||||||
|
The inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
|
||||||
|
artifact URL, module API root, module realtime root, and the viewer's granted permissions **for that
|
||||||
|
module** — and nothing else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because
|
||||||
|
navigation metadata is a separate design. It is authenticated (`core.modules.ui.read`, baseline
|
||||||
|
`authenticated`) precisely because it is the module enumeration the anonymous artifact route
|
||||||
|
deliberately withholds. All three URLs are produced by the server's own canonical helpers, so the
|
||||||
|
browser never rebuilds Ekklesia's module prefixes and any can change without touching host or module
|
||||||
|
code.
|
||||||
|
|
||||||
|
**The shell statically imports no module.** Every module it can mount arrives at run time from the
|
||||||
|
inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no
|
||||||
|
module id, no `/api/v1/modules`, no `eval`, and no blob URLs. Permission hints come from the server's
|
||||||
|
own evaluator and remain presentation only: the end-to-end test proves a viewer holding no grant is
|
||||||
|
still refused `403` when the UI calls the API anyway. The host owns the container, the loading state,
|
||||||
|
and every failure; a broken module costs its own panel, not the shell. See
|
||||||
|
[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md).
|
||||||
|
|
||||||
|
Phase 6D pass 1 gives that shell a real **navigation model** — and pins down the rule it could
|
||||||
|
easily have broken. A church sees `Media → Livestream, Video` and `Planning → Calendar`, while the
|
||||||
|
modules underneath stay exactly what they were: flat, independently versioned products with no
|
||||||
|
parent, no owner, and no folder-derived dependency.
|
||||||
|
|
||||||
|
**Grouping is presentation configuration, not module structure.** A group is a label on a rendering
|
||||||
|
bucket: no manifest, no version, no artifact, no API, no lifecycle, and nothing depends on it.
|
||||||
|
Placing Livestream and Video in one folder creates no relationship between them, and moving a module
|
||||||
|
changes one row. *Livestream depends on Video because it calls Video's functionality* is a real
|
||||||
|
dependency that orders startup; *Livestream is shown under Media* orders nothing. That distinction is
|
||||||
|
executable: a test discovers real fixtures with a genuine dependency, arranges them four ways
|
||||||
|
(including both in one folder, and the dependency inverted in presentation), and asserts discovery
|
||||||
|
order, dependency declarations, and the module set come out byte-identical every time.
|
||||||
|
|
||||||
|
**No manifest change, and no version 5.** `manifest.name` has been required since version 1 and is
|
||||||
|
already the module's product identity, so the shell simply renders "Livestream" instead of
|
||||||
|
`livestream`. `suggestedGroup` was deliberately deferred rather than shipped: it would force a hard
|
||||||
|
manifest-version cutover for a *hint*, and its only correct semantics needs initialization logic that
|
||||||
|
would risk a module's suggestion overwriting an administrator's decision.
|
||||||
|
|
||||||
|
Two Core tables (migration `0003`) hold groups and placements — server-side, because a church
|
||||||
|
configures its Dashboard, not one browser; platform-global, because Ekklesia has no organization
|
||||||
|
model yet. `GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the
|
||||||
|
host-neutral module UI inventory rather than merged into it, embedding each generic descriptor
|
||||||
|
untouched. Reading needs the same permission the inventory does; organizing needs a new platform
|
||||||
|
permission, `core.dashboard.navigation.manage`. A newly installed module appears immediately as
|
||||||
|
ungrouped with no initialization write; an uninstalled module's placement is retained so reinstalling
|
||||||
|
restores its position; deleting a group ungroups its modules through the database rather than
|
||||||
|
application cleanup. See
|
||||||
|
[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md).
|
||||||
|
|
||||||
|
Phase 7A pass 1 gives the platform an **operational module lifecycle** — the smallest one that is
|
||||||
|
actually useful, and nothing beyond it. Until now the accepted module set was exactly "whatever was
|
||||||
|
found on disk at startup", and `ModuleLifecycleStage` named nine states that nothing produced or
|
||||||
|
consumed. That vocabulary is replaced by two axes that real code produces:
|
||||||
|
|
||||||
|
```text
|
||||||
|
configuration enabled / disabled persistent, administrator-owned
|
||||||
|
runtime running / disabled / blocked observed per boot
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enablement is host configuration, and a module can never see it.** One Core table (migration
|
||||||
|
`0004`) holds `module_id` and `enabled` — no install source, download URL, update channel, signature,
|
||||||
|
or JSON blob, because those are the columns an installer would need and their absence is what stops
|
||||||
|
one being built by accident. A module never declares or reads its own enabled state, and is never
|
||||||
|
told it is enabled: its host loads and starts it, or does not. That is what keeps the same module
|
||||||
|
implementation runnable under a future standalone host.
|
||||||
|
|
||||||
|
**A newly discovered module is enabled, and the default is the absence of a row.** Deploying a module
|
||||||
|
is still sufficient to run it — no initialization write, no database edit — mirroring the rule that a
|
||||||
|
module with no Dashboard placement is still visible. An explicit record always wins, in both
|
||||||
|
directions, and a record for a module that is no longer deployed is retained and ignored, so
|
||||||
|
redeploying restores the decision.
|
||||||
|
|
||||||
|
**Required dependencies block; optional ones never do.** Disabling `storage` does not silently
|
||||||
|
re-enable it because something needs it — it makes every module that *requires* it ineligible,
|
||||||
|
transitively, with a structured reason (`required_dependency_disabled` →
|
||||||
|
`required_dependency_blocked`) naming the module an administrator must act on. A required *capability*
|
||||||
|
dependency blocks identically, because its only provider is a module. An optional dependency being
|
||||||
|
disabled is the same situation as it never being installed: the dependent starts, and
|
||||||
|
`capabilities.resolve(...)` returns `undefined`.
|
||||||
|
|
||||||
|
**Exposure follows eligibility, structurally.** `startApi` hands the same eligible list to the module
|
||||||
|
runtime and to the UI publication registry, so a disabled module is never imported at all: no hooks,
|
||||||
|
no routes (an unmounted path gets the ordinary `404` — there is no per-request enablement check
|
||||||
|
anywhere), no capabilities, no published artifact, and no entry in the browser inventory or Dashboard
|
||||||
|
navigation. Its **migrations are still discovered and applied**, because deployed schema ownership is
|
||||||
|
not runtime state, and its **permissions stay registered**, so grants survive an enable/disable cycle.
|
||||||
|
|
||||||
|
**Startup stays all-or-nothing, deliberately, and changes need a restart.** One module that fails to
|
||||||
|
start still takes API startup down; that guarantee is what ADR 0025/0026/0027 rest on, and inverting
|
||||||
|
it is a separate design. There is consequently no `failed` runtime state — no live process could
|
||||||
|
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable --
|
||||||
|
--module <id>` as the out-of-band path, since the HTTP API is served by the process a bad module
|
||||||
|
takes down. `PUT /api/v1/module-lifecycle/<id>/enabled` writes a decision and reports honestly that
|
||||||
|
the process has not changed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "configured": { "enabled": false, "explicit": true },
|
||||||
|
"runtime": { "state": "running" },
|
||||||
|
"restartRequired": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is hot-unloaded to make a button feel immediate. `running` means a module's `start` hook
|
||||||
|
completed and **nothing more** — a module that starts and then cannot reach its object storage has a
|
||||||
|
health problem, not a lifecycle problem, and this pass adds no health-probe contract. `pnpm doctor`
|
||||||
|
gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading
|
||||||
|
logs. Reading and changing both require the new restricted platform permission
|
||||||
|
`core.modules.lifecycle.manage`. Disabling a module is an **operational** control, not a sandbox: its
|
||||||
|
code is still on disk and still trusted. There is still **no installer**. See
|
||||||
|
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
|
||||||
|
|
||||||
|
Phase 7A pass 2 adds the **module configuration and secrets contract** — the thing standing between
|
||||||
|
Announcements and the module system, since that product currently has zero environment variables and
|
||||||
|
compiles a LAN IP address into its source.
|
||||||
|
|
||||||
|
A module declares what it needs configured in its manifest, and receives it as a frozen, validated
|
||||||
|
snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"configuration": [
|
||||||
|
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 },
|
||||||
|
{ "key": "bucket", "type": "string", "required": true },
|
||||||
|
{ "key": "storageCredential", "type": "secret", "required": true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
start({ config }) {
|
||||||
|
const slideSeconds = config.getInteger("slideSeconds");
|
||||||
|
const credential = config.getSecret("storageCredential");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Declaration is static, and that is the whole point.** The platform validates configuration
|
||||||
|
*before* deciding whether a module may start, so an enabled module missing a required value is
|
||||||
|
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
|
||||||
|
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||||
|
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||||
|
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||||
|
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
|
||||||
|
browser contributions.
|
||||||
|
|
||||||
|
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||||
|
no key management, so a `secret` key's value is supplied by the deployment
|
||||||
|
(`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`) and never touches PostgreSQL. The module never sees
|
||||||
|
that variable name — it calls `config.getSecret("storageCredential")` — which is exactly what lets a
|
||||||
|
future standalone host supply the same declared secret from somewhere else. A `secret` declaration has
|
||||||
|
no `default` field, the write API refuses secret material outright, and the HTTP view has no `value`
|
||||||
|
field for one, so a secret is absent from responses *by construction* rather than filtered by a branch
|
||||||
|
somebody could delete.
|
||||||
|
|
||||||
|
Six types (`string`, `integer`, `boolean`, `enum`, `url`, `secret`), bounded constraints, no nesting,
|
||||||
|
no regular expressions — a validation vocabulary, not a form-builder DSL. Nothing is coerced:
|
||||||
|
`{"slideSeconds": "7"}` is a `400`, and a stored value that fails its own declared constraints blocks
|
||||||
|
the module rather than being quietly replaced by the default. Ordinary values live in one Core table
|
||||||
|
(migration `0005`), one row per `(module_id, key)`; a key the current manifest no longer declares is
|
||||||
|
retained and ignored so a downgrade never destroys an operator's value.
|
||||||
|
|
||||||
|
Changes take effect at the **next restart**, and `GET`/`PUT /api/v1/module-configuration/<id>` report
|
||||||
|
`restartRequired` rather than pretending otherwise — a sibling prefix, because `config` is a legal
|
||||||
|
module route segment. Both verbs need the new restricted `core.modules.configuration.manage`,
|
||||||
|
deliberately separate from the lifecycle permission: running a module and configuring it are different
|
||||||
|
authorities. A setting that must change *without* a restart is the module's own domain state — like
|
||||||
|
Announcements' `mode` toggle — not platform configuration.
|
||||||
|
|
||||||
|
**There is no settings UI**, and module UI receives no configuration at all in this phase. Endpoints
|
||||||
|
and credentials for shared infrastructure stay platform concerns: a module declares which *bucket* it
|
||||||
|
needs, not an S3 endpoint, leaving room for the object storage service Core still lacks. See
|
||||||
|
[ADR 0029](docs/architecture/decisions/0029-module-configuration-and-secrets.md).
|
||||||
|
|
||||||
|
Phase 7A pass 3 turns `manifest.storage.buckets` — declarable since manifest version 1 and inert ever
|
||||||
|
since — into a real **host-provided object-storage capability**. This is what stands between
|
||||||
|
Announcements and the module system: it currently fetches `http://10.40.0.2:9000/announcements`, parses
|
||||||
|
S3 XML by hand, and hands the browser direct S3 URLs.
|
||||||
|
|
||||||
|
A module declares a **logical** bucket and gets a scoped facade:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"storage": { "buckets": [{ "id": "announcement-assets" }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const assets = context.storage.bucket("announcement-assets");
|
||||||
|
const object = await assets.get("slides/welcome.png");
|
||||||
|
return new Response(object.body, { headers: { "content-type": object.contentType } });
|
||||||
|
```
|
||||||
|
|
||||||
|
**The contract names no provider.** No endpoint, region, credential, or physical bucket is reachable
|
||||||
|
from it — not as a field, not as a type, not as a string. That is what lets the same module run under
|
||||||
|
Ekklesia and under a future standalone host backed by its own SeaweedFS, a shared one, MinIO, or AWS
|
||||||
|
S3. A module that configures its own S3 endpoint is a module welded to one deployment, which is the
|
||||||
|
mistake this pass exists to prevent.
|
||||||
|
|
||||||
|
Core's first provider speaks the S3-compatible protocol through the AWS SDK. SeaweedFS is the intended
|
||||||
|
target ([ADR 0004](docs/architecture/decisions/0004-replace-unmaintained-minio.md)) and already runs in
|
||||||
|
the development Compose stack — but it is a deployment choice, never an API name. Probing it directly
|
||||||
|
found a real divergence worth having caught: `CreateBucket` on an existing bucket reports
|
||||||
|
`BucketAlreadyExists`, **not** AWS's `BucketAlreadyOwnedByYou`, so treating only the AWS spelling as
|
||||||
|
success would have failed on every second boot. A live test against the real server pins it.
|
||||||
|
|
||||||
|
**Logical → physical mapping is deterministic and collision-safe.**
|
||||||
|
`ekklesia-announcements--announcement-assets-3f0c1d9a6b21`: an installation prefix (so several
|
||||||
|
deployments can share one storage service), the module and bucket ids joined by `--` — which cannot
|
||||||
|
occur inside either identifier, so `a-b`+`c` and `a`+`b-c` can never collide — and a digest over the
|
||||||
|
full untruncated identity. Two modules both declaring `assets` always get separate namespaces, and a
|
||||||
|
module can never see or choose the physical name.
|
||||||
|
|
||||||
|
**Declaring a bucket is now a real requirement.** With no provider configured, such a module is
|
||||||
|
reported `blocked · storage_unavailable` naming the logical bucket, and its required dependents block
|
||||||
|
behind it through the existing resolver — no second eligibility system. An installation with *no*
|
||||||
|
object storage remains a complete deployment; modules declaring no bucket are unaffected, which is why
|
||||||
|
the template now declares none.
|
||||||
|
|
||||||
|
**A storage outage is not a lifecycle event.** Eligibility asks whether storage is *configured*, never
|
||||||
|
whether it answered. Bucket creation is lazy on first use rather than at startup, so a storage service
|
||||||
|
being down can never stop the platform from booting — and a running module whose object store blinks
|
||||||
|
stays `running`, catches a structured error, and retries, exactly as Announcements does today. Nothing
|
||||||
|
ever deletes a bucket, on disable or otherwise.
|
||||||
|
|
||||||
|
Credentials are deployment-supplied (`S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
|
||||||
|
`S3_BUCKET_PREFIX`), never persisted, never routed through module configuration, and never visible to
|
||||||
|
modules, module UI, diagnostics, or any API response — errors are translated to a closed code set that
|
||||||
|
carries no endpoint, bucket, request id, or XML. This also retires the transitional `bucket`
|
||||||
|
*configuration* key ADR 0029 flagged: the manifest declaration is the requirement.
|
||||||
|
|
||||||
|
Multipart upload is deliberately deferred, and the provider is not yet "production-ready" by
|
||||||
|
ADR 0004's own bar. See
|
||||||
|
[ADR 0030](docs/architecture/decisions/0030-host-provided-module-object-storage.md).
|
||||||
|
|
||||||
|
Phase 7A pass 4 takes Ekklesia **out of the data path** for assets a browser is going to fetch
|
||||||
|
anyway. A module asks its logical bucket for a temporary browser URL and returns it; the browser
|
||||||
|
fetches the object from the storage service directly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { url, expiresAt } = await context.storage
|
||||||
|
.bucket("announcement-assets")
|
||||||
|
.createReadUrl("slides/welcome.png");
|
||||||
|
```
|
||||||
|
|
||||||
|
The contract still names no mechanism — no signing, no endpoint, no expiry format — so a host with no
|
||||||
|
S3 in it could satisfy the same method. The URL is **opaque** to module code and a **bearer
|
||||||
|
capability** to whoever holds it, so a module authorizes the request in its own route before asking
|
||||||
|
for one: storage bounds *reach*, never *identity*. Lifetimes run from one minute to six hours,
|
||||||
|
fifteen minutes by default, and out-of-range requests are refused rather than clamped. Modules
|
||||||
|
persist logical keys and generate URLs on demand; a stored URL would be a stored credential.
|
||||||
|
|
||||||
|
This is what makes media practical. A `Range` request against a delivery URL is answered by the
|
||||||
|
storage service directly — verified `206` with a correct `Content-Range` — so byte-range seeking needs
|
||||||
|
no proxy in Core. Signing is local, so a slideshow builds one URL per slide with no round trips, and
|
||||||
|
repeated calls inside a window return a byte-identical URL so browser caches and URL-keyed DOM
|
||||||
|
elements hold.
|
||||||
|
|
||||||
|
Because an S3-compatible signature covers the request's `Host`, a URL cannot be signed for one origin
|
||||||
|
and rewritten to another. The origin a browser uses is therefore a signing input
|
||||||
|
(`S3_DELIVERY_ENDPOINT`), defaulting to `S3_ENDPOINT` — so the standard deployment configures nothing
|
||||||
|
and delivery simply works. A reverse proxy in front of storage must pass the original `Host` through
|
||||||
|
unchanged; it may terminate TLS, because the scheme is not signed. Buckets stay private: an unsigned
|
||||||
|
request for the same object is refused, and a test asserts it. **Signed write and delete URLs are
|
||||||
|
deliberately deferred.** See
|
||||||
|
[ADR 0031](docs/architecture/decisions/0031-direct-object-delivery.md).
|
||||||
|
|
||||||
|
Phase 7A pass 5 adds native, host-neutral module realtime
|
||||||
|
([ADR 0032](docs/architecture/decisions/0032-native-module-realtime-transport.md)). During
|
||||||
|
`register`, a module declares a small, module-local channel and the permission that protects it;
|
||||||
|
during `start`, running work, and HTTP handlers, it publishes bounded JSON state through its own
|
||||||
|
scoped publisher. The publisher is inert by the time that module's stop hook begins. Core keeps only
|
||||||
|
the latest state and one pending update per subscriber. A module
|
||||||
|
must publish an initial state before startup completes, so every accepted subscriber receives one
|
||||||
|
atomic authoritative snapshot and then monotonically sequenced updates without a GET-then-subscribe
|
||||||
|
race. Slow clients never block publishers and intermediate states may be coalesced.
|
||||||
|
|
||||||
|
Ekklesia carries that contract over permission-gated Server-Sent Events, with cookie authentication,
|
||||||
|
15-second transport-owned keepalives, a five-minute reauthorization cycle, browser-native reconnect,
|
||||||
|
and a fresh snapshot on every reconnection. The module UI API remains transport-neutral:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const subscription = context.realtime.subscribe<SlideshowState>("state", {
|
||||||
|
onSnapshot: (state) => render(state),
|
||||||
|
onUpdate: (state) => render(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return this from `mount`, or call it from your unmount cleanup.
|
||||||
|
subscription.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
|
||||||
|
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
|
||||||
|
pass through realtime unchanged.
|
||||||
|
|
||||||
|
Phase 7A pass 6 adds the smallest complete **authenticated display/kiosk host**
|
||||||
|
([ADR 0033](docs/architecture/decisions/0033-display-kiosk-host-contract.md)). Manifest version 6
|
||||||
|
adds an optional, independently declared display role:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entrypoints": {
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "announcements.display.view"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The permission must be declared by that module and have an `authenticated` or `restricted` baseline. Primary UI and display UI are independently optional, must
|
||||||
|
use paths distinct from every other entrypoint, and use the same
|
||||||
|
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
|
||||||
|
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
|
||||||
|
`src/display/index.tsx`) and atomically emits every declared role.
|
||||||
|
|
||||||
|
Phase 7B pass 2 gives those screens an identity of their own
|
||||||
|
([ADR 0036](docs/architecture/decisions/0036-managed-device-identity-and-pairing.md)), so a
|
||||||
|
television no longer needs a person signed into it. Ekklesia now recognizes **three** principal
|
||||||
|
classes — anonymous, authenticated human, and managed device:
|
||||||
|
|
||||||
|
```text
|
||||||
|
screen opens /display/announcements → no identity → shows a 6-digit code
|
||||||
|
administrator types the code in the Dashboard, names the screen, approves
|
||||||
|
screen collects an HttpOnly credential → display and realtime work
|
||||||
|
administrator revokes → the screen loses access and shows a code again
|
||||||
|
```
|
||||||
|
|
||||||
|
**A device is not a user.** It has no email, no password, and no row in the identity tables, and it
|
||||||
|
satisfies the `authenticated` baseline *never* — so every route that settles for "somebody is signed
|
||||||
|
in" stays closed to a screen on a wall. Device authority runs through the same authorization
|
||||||
|
evaluator as everyone else's, and approval grants exactly the one display permission the host derived
|
||||||
|
from the module's own declaration. `/api/v1/principal` reports a device as anonymous on purpose: a
|
||||||
|
device cookie cannot open the Dashboard.
|
||||||
|
|
||||||
|
**The code is not the credential.** Six digits are printed across a television in a public hallway,
|
||||||
|
so they are treated as public: they identify a request to an administrator and yield nothing on their
|
||||||
|
own. Collecting the credential also requires an HttpOnly claim cookie only the requesting browser
|
||||||
|
holds, and approving requires the restricted `core.devices.manage` permission. The credential itself
|
||||||
|
is stored only as a SHA-256 digest, never logged, never in a URL, and never readable by the module
|
||||||
|
display code running in that same page.
|
||||||
|
|
||||||
|
Revocation is a server-side fact checked on every request, not a plea that a remote browser forget
|
||||||
|
something; re-pairing creates a **new** identity so an old credential can never come back. A revoked
|
||||||
|
screen fails its next request immediately, though an already-open realtime stream is bounded by the
|
||||||
|
existing five-minute forced reconnect rather than closed instantly. **QR scanning, per-device content
|
||||||
|
targeting, device groups, and mobile dashboards are deliberately deferred** — a future QR simply
|
||||||
|
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
|
||||||
|
identity at all.
|
||||||
|
|
||||||
|
Phase 7C pass 1 adds the platform's first **outbound email transport**
|
||||||
|
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
|
||||||
|
authenticate people and pair devices but could not send a single message, which is what blocked
|
||||||
|
email verification, password recovery, and invitations alike. Email is now a host-provided
|
||||||
|
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
|
||||||
|
this transactional message* and the provider owns the host, port, TLS, credentials, and every
|
||||||
|
transport error. **No provider's hostname appears anywhere in Core**, and a test asserts it —
|
||||||
|
Google Workspace SMTP Relay is the expected first deployment, not a platform dependency.
|
||||||
|
|
||||||
|
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
|
||||||
|
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
|
||||||
|
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
|
||||||
|
mail appears to come from. Control characters in addresses, subjects, and display names are refused
|
||||||
|
rather than stripped, which is what closes header injection.
|
||||||
|
|
||||||
|
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
|
||||||
|
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
|
||||||
|
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
|
||||||
|
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
|
||||||
|
relay authorized by source IP has neither — and the password is a deployment secret that never
|
||||||
|
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
|
||||||
|
|
||||||
|
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
|
||||||
|
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
|
||||||
|
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
|
||||||
|
single bounded attempt and deliberately not an exactly-once promise. There is **no
|
||||||
|
`POST /api/v1/send-email`** and no route that touches the provider.
|
||||||
|
|
||||||
|
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
|
||||||
|
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
|
||||||
|
while stating plainly that a successful connection does not establish deliverability. See
|
||||||
|
[Outbound email](docs/development/email.md).
|
||||||
|
|
||||||
|
Phase 7C pass 2 completes the human identity lifecycle over that transport
|
||||||
|
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
|
||||||
|
verified email address is a precondition for holding a session**: the identity system refuses an
|
||||||
|
unverified sign-in after checking the password and before creating one, so a correct password on an
|
||||||
|
unverified address carries exactly the authority of no password at all — which is why no
|
||||||
|
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
|
||||||
|
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
|
||||||
|
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
|
||||||
|
can steer where a recipient lands, and following a verification link deliberately mints **no
|
||||||
|
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
|
||||||
|
leaves managed devices untouched. Password recovery answers identically for an address with an
|
||||||
|
account, without one, already verified, or undeliverable — including when SMTP is broken, which is
|
||||||
|
why the delivery adapter never throws.
|
||||||
|
|
||||||
|
**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates
|
||||||
|
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
|
||||||
|
can be signed in to before any relay exists. Normal production use of verification and recovery does
|
||||||
|
require configured outbound email. **Public signup remains disabled** — verification is not a licence
|
||||||
|
to self-register — and invitations and user administration are still not built. See
|
||||||
|
[Identity](docs/development/identity.md).
|
||||||
|
|
||||||
|
Ekklesia publishes the display bundle at
|
||||||
|
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
||||||
|
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
|
||||||
|
`GET /api/v1/module-displays/<module-id>`. The display surface is a separate full-viewport browser
|
||||||
|
application with no Dashboard chrome, no static module imports, and manual retry on failure. Only
|
||||||
|
eligible, running modules receive routes; manifest changes take effect on restart. Artifact bytes
|
||||||
|
remain anonymous trusted code, while the page and descriptor use normal cookie authentication and
|
||||||
|
the module-owned display permission. Realtime subscriptions and opaque signed object-delivery URLs
|
||||||
|
flow through the unchanged host context. Display inventory/management, variants and targeting,
|
||||||
|
public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the
|
||||||
|
Announcements migration, and Companion integration remain deferred.
|
||||||
|
|
||||||
|
Phase 7B pass 1 adds **host-owned browser authentication to the Dashboard shell**
|
||||||
|
([ADR 0034](docs/architecture/decisions/0034-host-owned-browser-authentication.md)). Anonymous
|
||||||
|
bootstrap renders an accessible email/password form; successful submission uses the existing
|
||||||
|
`POST /api/auth/sign-in/email` endpoint, whose successful browser-visible body is reduced to fixed
|
||||||
|
`{"ok":true}` while the HttpOnly cookie remains authoritative. The shell resolves
|
||||||
|
`GET /api/v1/principal` again before protected navigation and module UI, and sign-out uses the
|
||||||
|
existing `POST /api/auth/sign-out` before the same principal re-resolution. No token is stored or
|
||||||
|
passed to a module, no arbitrary callback/return URL exists, and leaving authenticated state
|
||||||
|
unmounts the active module through the generic loader's normal cleanup.
|
||||||
|
|
||||||
|
The flow intentionally offers neither signup nor password recovery: production signup remains
|
||||||
|
prohibited, no email sender exists, and the repository still lacks a supported way to create the
|
||||||
|
initial production identity required by platform-owner bootstrap. Authentication throttling is
|
||||||
|
enabled but in-memory and per API process. Host-only cookies, exact trusted origins, and Better
|
||||||
|
Auth's origin/CSRF checks remain unchanged; production Dashboard DNS/TLS/proxy/CSP and built-asset
|
||||||
|
hosting are deployment work, not guarantees added by this pass. Display device identity and the
|
||||||
|
public Website remain separate future designs.
|
||||||
|
|
||||||
|
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||||
|
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||||||
|
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||||||
|
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
||||||
|
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||||||
|
standalone host, and every real feature module also do not exist.
|
||||||
|
|
||||||
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
||||||
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||||||
@@ -300,6 +794,74 @@ unproven launch-blockers rather than existing guarantees. Read
|
|||||||
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
||||||
module or exposing the API.
|
module or exposing the API.
|
||||||
|
|
||||||
|
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
|
||||||
|
None of them is a feature module, and none of them names one.
|
||||||
|
|
||||||
|
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
|
||||||
|
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
|
||||||
|
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
|
||||||
|
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
|
||||||
|
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
|
||||||
|
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
|
||||||
|
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
|
||||||
|
badge, toast, and empty/error/loading states), every value a token reference, with real
|
||||||
|
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
|
||||||
|
exactly two shared roles to modules by name and pins both design packages beneath every other role.
|
||||||
|
The Dashboard and display install the defaults at their document root; custom properties inherit into
|
||||||
|
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
|
||||||
|
theme editor, brand builder, second theme, or component catalogue exists.**
|
||||||
|
|
||||||
|
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
|
||||||
|
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
|
||||||
|
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
|
||||||
|
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
|
||||||
|
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
|
||||||
|
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
|
||||||
|
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
|
||||||
|
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
|
||||||
|
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
|
||||||
|
is versioned with no dual acceptance; migrating is one `pnpm build`.
|
||||||
|
|
||||||
|
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||||||
|
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||||
|
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
|
||||||
|
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||||
|
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||||||
|
means. Publication requires that another eligible module provides the capability inside the declared
|
||||||
|
range; a contribution that fails is absent rather than broken. A consumer discovers through
|
||||||
|
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
|
||||||
|
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
|
||||||
|
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
|
||||||
|
database, storage, or credential — and loading one grants its module nothing.
|
||||||
|
|
||||||
|
Phase 8 pass 4 closes the object-storage write path
|
||||||
|
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
|
||||||
|
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
|
||||||
|
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
|
||||||
|
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
|
||||||
|
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
|
||||||
|
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
|
||||||
|
oversize is refused before authorization, the buffered path now counts what arrived rather than
|
||||||
|
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
|
||||||
|
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
|
||||||
|
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
|
||||||
|
|
||||||
|
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
|
||||||
|
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
|
||||||
|
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
|
||||||
|
every contributed renderer that display mounted, network-first so a screen can never be trapped on
|
||||||
|
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
|
||||||
|
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
|
||||||
|
stated rather than papered over — a display cannot render module content while offline, because
|
||||||
|
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
|
||||||
|
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
|
||||||
|
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
|
||||||
|
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
|
||||||
|
the module's responsibility and do not exist yet.**
|
||||||
|
|
||||||
|
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||||
|
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
@@ -319,8 +881,18 @@ pnpm verify
|
|||||||
|
|
||||||
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
|
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
|
||||||
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
|
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
|
||||||
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration
|
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage and
|
||||||
state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
outbound-email integration state.
|
||||||
|
|
||||||
|
Doctor reads its host infrastructure configuration through the same
|
||||||
|
`loadHostConfiguration(process.env)` the API executable uses, so both derive identical settings and
|
||||||
|
identical defaults from one deployment environment: configured object storage is reported as
|
||||||
|
configured, a module secret the deployment has set is reported as satisfied, and a value neither
|
||||||
|
process could accept is reported as a configuration failure rather than silently interpreted one way
|
||||||
|
by the diagnostic and another way at startup. An optional service that is genuinely absent — no
|
||||||
|
`S3_ENDPOINT`, no `EKKLESIA_SMTP_HOST` — is still a complete deployment and reported as skipped.
|
||||||
|
|
||||||
|
`.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
||||||
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
||||||
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
|
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
|
||||||
development infrastructure provides is only usable with it set.
|
development infrastructure provides is only usable with it set.
|
||||||
@@ -345,16 +917,24 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
1 permission registered
|
6 permissions registered
|
||||||
|
○ Module lifecycle skipped
|
||||||
|
DATABASE_URL is not configured.
|
||||||
|
○ Module configuration skipped
|
||||||
|
DATABASE_URL is not configured.
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
0 modules · 0 tables
|
0 modules · 0 tables
|
||||||
✓ Migrations validated
|
✓ Migrations validated
|
||||||
3 migrations · checksums valid
|
7 migrations · checksums valid
|
||||||
core:0000_identity_core_tables · f8f2086ef06f
|
core:0000_identity_core_tables · f8f2086ef06f
|
||||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||||
core:0002_authorization_integrity · 634294731180
|
core:0002_authorization_integrity · 634294731180
|
||||||
|
core:0003_dashboard_navigation_tables · 2cde27807aa4
|
||||||
|
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||||
|
core:0005_module_configuration · c8d57f24f84b
|
||||||
|
core:0006_device_identity · 1db97b7e3ac5
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
✓ Database TLS verified
|
✓ Database TLS verified
|
||||||
@@ -368,10 +948,18 @@ Identity
|
|||||||
○ Identity configuration skipped
|
○ Identity configuration skipped
|
||||||
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
||||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||||
|
✓ Direct client-address mode
|
||||||
|
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||||
|
|
||||||
Object storage
|
Object storage
|
||||||
○ Check unavailable
|
○ Object storage not configured
|
||||||
Core storage integration has not been implemented yet.
|
No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
|
||||||
|
✓ No module requires object storage
|
||||||
|
0 modules declare a logical storage bucket.
|
||||||
|
|
||||||
|
Outbound email
|
||||||
|
○ Outbound email not configured
|
||||||
|
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
|
||||||
|
|
||||||
Result: READY WITH WARNINGS
|
Result: READY WITH WARNINGS
|
||||||
```
|
```
|
||||||
@@ -395,6 +983,51 @@ CLI into a temporary location and structurally compares it against the checked-i
|
|||||||
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
|
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
|
||||||
workflow.
|
workflow.
|
||||||
|
|
||||||
|
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record which
|
||||||
|
installed modules this deployment runs. They read `DATABASE_URL`, run the same manifest-only
|
||||||
|
discovery every other command does (so a module this deployment cannot see is refused), write one
|
||||||
|
row, and start nothing — the change takes effect at the next `pnpm api:start`. They exist so a module
|
||||||
|
that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by
|
||||||
|
the process such a module takes down.
|
||||||
|
|
||||||
|
### Getting the first administrator into a fresh installation
|
||||||
|
|
||||||
|
A new installation has a login page and nothing to log in to: public registration is disabled by
|
||||||
|
default and cannot be enabled in production at all. One explicit, operator-invoked command creates
|
||||||
|
the initial identity and grants it platform ownership
|
||||||
|
([ADR 0035](docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ... up -d # PostgreSQL and the rest of the stack
|
||||||
|
pnpm migrations:apply # the schema must already exist
|
||||||
|
pnpm identity:create-initial-owner -- --email [email protected]
|
||||||
|
pnpm api:start # then sign in at the Dashboard login page
|
||||||
|
```
|
||||||
|
|
||||||
|
It prompts for the password twice, invisibly, and never accepts one as a command-line argument —
|
||||||
|
`--password` is refused, because an argument is visible in shell history and in the process list.
|
||||||
|
For automated deployment it reads the password from standard input instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
**No environment variable is read for the password and none belongs in `.env`**: it is used once to
|
||||||
|
create an account, not kept as deployment configuration. The command needs `DATABASE_URL` and
|
||||||
|
nothing else — no identity secret, no trusted origins, and no public base URL — so the first owner
|
||||||
|
can be created on a freshly provisioned server before TLS or DNS exist.
|
||||||
|
|
||||||
|
It runs **once**. Ownership bootstrap is single-use, so a second run is refused: it creates no
|
||||||
|
second owner, changes no existing password, and leaves the first owner untouched. It also never
|
||||||
|
applies migrations — an unmigrated database is reported, not silently fixed. If it created the
|
||||||
|
identity but failed before granting ownership, running the identical command again adopts that
|
||||||
|
identity and finishes, leaving its password alone. Creating *additional* users and owners belongs to
|
||||||
|
user administration, which does not exist yet.
|
||||||
|
|
||||||
|
This is not public signup and not a browser setup wizard: there is no `/signup`, `/register`, or
|
||||||
|
`/setup` route, no unauthenticated bootstrap endpoint, and no setup token. The login page stays
|
||||||
|
login-only.
|
||||||
|
|
||||||
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
|
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
|
||||||
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
|
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
|
||||||
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
|
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -21,16 +21,22 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/contracts": "workspace:*",
|
"@ekklesia/contracts": "workspace:*",
|
||||||
"@ekklesia/core": "workspace:*",
|
"@ekklesia/core": "workspace:*",
|
||||||
|
"@ekklesia/shell": "workspace:*",
|
||||||
"@hono/node-server": "2.0.10",
|
"@hono/node-server": "2.0.10",
|
||||||
"hono": "4.12.31"
|
"hono": "4.12.31"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@ekklesia/module-ui-host": "workspace:*",
|
||||||
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
|
"@types/smtp-server": "3.5.13",
|
||||||
"better-auth": "1.6.23",
|
"better-auth": "1.6.23",
|
||||||
|
"happy-dom": "20.0.11",
|
||||||
"pg": "8.22.0",
|
"pg": "8.22.0",
|
||||||
|
"smtp-server": "3.19.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
+333
-9
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
type ModuleRuntimeServices,
|
type ModuleRuntimeServices,
|
||||||
|
type ModuleSecretSource,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
@@ -8,13 +9,38 @@ import type {
|
|||||||
AuthorizationStore,
|
AuthorizationStore,
|
||||||
PermissionRegistry,
|
PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} 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 EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import { Hono } from "hono";
|
import type {
|
||||||
|
DiscoveredModule,
|
||||||
|
ModuleConfigurationLookup,
|
||||||
|
ModuleConfigurationSettings,
|
||||||
|
ModuleConfigurationStore,
|
||||||
|
ModuleLifecycleSnapshot,
|
||||||
|
ModuleLifecycleStore,
|
||||||
|
ModuleRealtimeRegistry,
|
||||||
|
ModuleStorageLookup,
|
||||||
|
ModuleUiPublicationRegistry,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
import type { Context, MiddlewareHandler } from "hono";
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||||
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
|
import { createDeviceRoutes } from "./device-routes.js";
|
||||||
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
|
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
|
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||||
|
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-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 {
|
export interface CreateApiAppAuthorizationOptions {
|
||||||
readonly evaluator: AuthorizationEvaluator;
|
readonly evaluator: AuthorizationEvaluator;
|
||||||
@@ -33,11 +59,101 @@ export interface CreateApiAppOptions {
|
|||||||
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
||||||
* implicit fallback. */
|
* implicit fallback. */
|
||||||
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
||||||
|
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
|
||||||
|
* empty, for the same reason as module routes: application composition never guesses what ran. */
|
||||||
|
readonly moduleRealtime: ModuleRealtimeRegistry;
|
||||||
/** The same frozen services object the caller's started module runtime already passed to every
|
/** The same frozen services object the caller's started module runtime already passed to every
|
||||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
||||||
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
||||||
* `ModuleDatabase` for the lifetime of this API runtime. */
|
* `ModuleDatabase` for the lifetime of this API runtime. */
|
||||||
readonly moduleServices: ModuleRuntimeServices;
|
readonly moduleServices: ModuleRuntimeServices;
|
||||||
|
/** 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;
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||||
@@ -52,6 +168,87 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
|||||||
return c.json({ code }, status);
|
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
|
* 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
|
* dynamic-function `origin` — including one that already has its own `Vary` (module responses
|
||||||
@@ -118,6 +315,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
app.use("/api/auth/*", corsMiddleware);
|
app.use("/api/auth/*", corsMiddleware);
|
||||||
app.use("/api/v1/*", 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);
|
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
|
||||||
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
|
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -126,13 +336,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
|
// 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,
|
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
|
||||||
// and never reads or buffers the body.
|
// and never reads or buffers the body.
|
||||||
const headers = new Headers(response.headers);
|
return browserSafeAuthResponse(c.req.raw, response);
|
||||||
headers.set("Cache-Control", "no-store");
|
|
||||||
return new Response(response.body, {
|
|
||||||
headers,
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sanitizedErrorResponse(c, error, 500);
|
return sanitizedErrorResponse(c, error, 500);
|
||||||
}
|
}
|
||||||
@@ -154,6 +358,16 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
|
||||||
|
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
|
||||||
|
// own entirely. See `./health-routes.js`.
|
||||||
|
app.route("/", createHealthRoutes(options.health));
|
||||||
|
|
||||||
|
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||||
|
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||||
|
// `./dashboard-host-routes.js`.
|
||||||
|
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||||
|
|
||||||
app.route(
|
app.route(
|
||||||
"/",
|
"/",
|
||||||
createGrantsRoutes({
|
createGrantsRoutes({
|
||||||
@@ -165,10 +379,120 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
mountModuleHttpRoutes(app, options.moduleRoutes, {
|
// 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,
|
evaluator: options.authorization.evaluator,
|
||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
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,
|
services: options.moduleServices,
|
||||||
trustedOrigins: allowedOrigins,
|
trustedOrigins: allowedOrigins,
|
||||||
});
|
});
|
||||||
|
|||||||
+187
-17
@@ -1,6 +1,10 @@
|
|||||||
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
|
import {
|
||||||
import type { Context } from "hono";
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
import { bodyLimit } from "hono/body-limit";
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
type ModuleRouteBodyMode,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
||||||
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
|
|||||||
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
||||||
* abuse.
|
* abuse.
|
||||||
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
||||||
* constant a module's own route declaration is validated against at registration) is the hard
|
* constant a module's own **buffered** route declaration is validated against at registration) is
|
||||||
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
|
* the ceiling for every body the platform materializes before a handler runs. It has not moved.
|
||||||
* nothing in this platform ever declares, or enforces, a larger single request body than this.
|
* - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
|
||||||
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
|
* explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
|
||||||
* a future dedicated storage design, not to widening this ceiling.
|
* `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
|
||||||
|
* single request body, and no unbounded option exists anywhere in the contract.
|
||||||
*/
|
*/
|
||||||
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
||||||
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
||||||
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING };
|
export {
|
||||||
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
};
|
||||||
|
|
||||||
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
||||||
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
||||||
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
|
|||||||
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
|
/**
|
||||||
* contract rather than redefining `onError` per route. Covers both a declared oversized
|
* A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
|
||||||
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
|
*
|
||||||
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
|
* A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
|
||||||
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
|
* the byte counting below bounds the request either way, so refusing here could only ever turn a
|
||||||
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
|
* legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
|
||||||
export function platformBodyLimit(maxSize: number) {
|
* parser rejects genuinely malformed framing before this code sees the request at all.
|
||||||
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
|
*/
|
||||||
|
function declaredContentLength(request: Request): number | undefined {
|
||||||
|
const header = request.headers.get("content-length");
|
||||||
|
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Number.parseInt(header.trim(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
|
||||||
|
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
|
||||||
|
function releaseUnreadBody(request: Request): void {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body || body.locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void body.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
|
||||||
|
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
|
||||||
|
* chunk past the limit rather than the whole upload.
|
||||||
|
*/
|
||||||
|
async function readBounded(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
maxSize: number,
|
||||||
|
): Promise<readonly Uint8Array[] | "over-limit"> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
size += value.byteLength;
|
||||||
|
if (size > maxSize) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
return "over-limit";
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's one request-body limiter, for every route in the application.
|
||||||
|
*
|
||||||
|
* ## Both modes reject a declared oversize before anything else happens
|
||||||
|
*
|
||||||
|
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
|
||||||
|
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
|
||||||
|
* too-large upload takes.
|
||||||
|
*
|
||||||
|
* ## `buffered` — the body is materialized, bounded, before the handler runs
|
||||||
|
*
|
||||||
|
* The exact behaviour every route had before streaming existed, now with the counting done here
|
||||||
|
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
|
||||||
|
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
|
||||||
|
* what actually arrived rather than by what was claimed. The handler then receives a request whose
|
||||||
|
* body is fully available and provably within its declared allowance — which is what makes
|
||||||
|
* `await request.json()` safe in a module handler.
|
||||||
|
*
|
||||||
|
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
|
||||||
|
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
|
||||||
|
*
|
||||||
|
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
|
||||||
|
*
|
||||||
|
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
|
||||||
|
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
|
||||||
|
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
|
||||||
|
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
|
||||||
|
* is not a limit.
|
||||||
|
*
|
||||||
|
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
|
||||||
|
* handler has started, so a handler that persists as it reads has to be prepared for a partially
|
||||||
|
* consumed upload. Writing to a temporary key and committing only after the read completes is the
|
||||||
|
* pattern; the platform cannot do it for you, because only the module knows what a partial write
|
||||||
|
* means to its own data.
|
||||||
|
*
|
||||||
|
* ## Ordering
|
||||||
|
*
|
||||||
|
* This runs before permission evaluation for both modes, so a request that is *declared* too large
|
||||||
|
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
|
||||||
|
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
|
||||||
|
* push a large upload through the platform at all.
|
||||||
|
*
|
||||||
|
* ## Timeouts
|
||||||
|
*
|
||||||
|
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
|
||||||
|
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
|
||||||
|
* platform already has one answer for that rather than a second per-route one.
|
||||||
|
*/
|
||||||
|
export function platformBodyLimit(
|
||||||
|
maxSize: number,
|
||||||
|
mode: ModuleRouteBodyMode = "buffered",
|
||||||
|
): MiddlewareHandler {
|
||||||
|
return async function enforceBodyLimit(c, next) {
|
||||||
|
const request = c.req.raw;
|
||||||
|
const declared = declaredContentLength(request);
|
||||||
|
if (declared !== undefined && declared > maxSize) {
|
||||||
|
return requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
if (!request.body) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "buffered") {
|
||||||
|
const chunks = await readBounded(request.body, maxSize);
|
||||||
|
if (chunks === "over-limit") {
|
||||||
|
return requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
c.req.raw = new Request(request, {
|
||||||
|
body: streamOf(chunks),
|
||||||
|
// Required by undici whenever a `Request` is constructed from a stream.
|
||||||
|
duplex: "half",
|
||||||
|
} as RequestInit);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let overLimit = false;
|
||||||
|
let total = 0;
|
||||||
|
const counted = request.body.pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
total += chunk.byteLength;
|
||||||
|
if (total > maxSize) {
|
||||||
|
overLimit = true;
|
||||||
|
// Erroring the transform propagates back to the source and cancels it, so the socket is
|
||||||
|
// released rather than left draining an upload nobody will read.
|
||||||
|
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
if (overLimit) {
|
||||||
|
c.res = requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
releaseUnreadBody(c.req.raw);
|
||||||
|
return;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned
|
||||||
|
* asset routes that answer `304` against a strong validator they computed from their own bytes.
|
||||||
|
*/
|
||||||
|
export function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||||
|
if (headerValue === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (headerValue.trim() === "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return headerValue.split(",").some((candidate) => {
|
||||||
|
const value = candidate.trim();
|
||||||
|
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||||
|
});
|
||||||
|
}
|
||||||
+67
-2
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
assertProductionDatabaseTransport,
|
assertProductionDatabaseTransport,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
type DatabaseTransportPolicy,
|
type DatabaseTransportPolicy,
|
||||||
type DatabaseTransportPolicyInput,
|
type DatabaseTransportPolicyInput,
|
||||||
createDatabaseTransportPolicy,
|
|
||||||
} from "@ekklesia/core/database";
|
} from "@ekklesia/core/database";
|
||||||
import {
|
import {
|
||||||
type CreateIdentityConfigOptions,
|
type CreateIdentityConfigOptions,
|
||||||
@@ -10,6 +10,16 @@ import {
|
|||||||
type IdentityConfig,
|
type IdentityConfig,
|
||||||
type IdentityConfigInput,
|
type IdentityConfigInput,
|
||||||
} from "@ekklesia/core/identity";
|
} 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 {
|
import {
|
||||||
type ClientAddressConfig,
|
type ClientAddressConfig,
|
||||||
type ClientAddressConfigInput,
|
type ClientAddressConfigInput,
|
||||||
@@ -55,12 +65,34 @@ export interface ApiRuntimeConfig {
|
|||||||
readonly host: string;
|
readonly host: string;
|
||||||
readonly identity: IdentityConfig;
|
readonly identity: IdentityConfig;
|
||||||
readonly port: number;
|
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 {
|
export interface ApiRuntimeConfigInput {
|
||||||
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
|
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
|
||||||
* trust unless explicitly configured. See `./client-address.js`. */
|
* trust unless explicitly configured. See `./client-address.js`. */
|
||||||
readonly clientAddress?: ClientAddressConfigInput;
|
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||||
readonly databaseUrl?: unknown;
|
readonly databaseUrl?: unknown;
|
||||||
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
|
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
|
||||||
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
|
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
|
||||||
@@ -68,6 +100,10 @@ export interface ApiRuntimeConfigInput {
|
|||||||
readonly host?: unknown;
|
readonly host?: unknown;
|
||||||
readonly identity: IdentityConfigInput;
|
readonly identity: IdentityConfigInput;
|
||||||
readonly port?: unknown;
|
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 {
|
export interface CreateApiRuntimeConfigOptions {
|
||||||
@@ -142,12 +178,41 @@ export function createApiRuntimeConfig(
|
|||||||
// the URL carries TLS parameters that could override the policy.
|
// the URL carries TLS parameters that could override the policy.
|
||||||
assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment);
|
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({
|
return Object.freeze({
|
||||||
clientAddress,
|
clientAddress,
|
||||||
databaseTransport,
|
databaseTransport,
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
host,
|
host,
|
||||||
identity,
|
identity,
|
||||||
|
mail,
|
||||||
port,
|
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;
|
||||||
|
}
|
||||||
@@ -3,25 +3,62 @@ import {
|
|||||||
type AuthorizationDrizzleDatabase,
|
type AuthorizationDrizzleDatabase,
|
||||||
createAuthorizationDrizzleView,
|
createAuthorizationDrizzleView,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
createDashboardDrizzleView,
|
||||||
|
type DashboardDrizzleDatabase,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
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 { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||||
import { createModuleDatabaseView } from "@ekklesia/core/modules";
|
import {
|
||||||
|
createModuleConfigurationView,
|
||||||
|
createModuleDatabaseView,
|
||||||
|
createModuleLifecycleView,
|
||||||
|
type ModuleConfigurationDrizzleDatabase,
|
||||||
|
type ModuleLifecycleDrizzleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
|
||||||
export interface ApiDatabaseHandle {
|
export interface ApiDatabaseHandle {
|
||||||
readonly authorization: AuthorizationDrizzleDatabase;
|
readonly authorization: AuthorizationDrizzleDatabase;
|
||||||
|
readonly devices: DeviceDrizzleDatabase;
|
||||||
|
readonly dashboard: DashboardDrizzleDatabase;
|
||||||
readonly identity: IdentityDrizzleDatabase;
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
|
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||||
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||||
|
/**
|
||||||
|
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
||||||
|
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
||||||
|
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
||||||
|
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
||||||
|
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
||||||
|
* the pool discards that connection instead of returning it, so a transaction left open by a
|
||||||
|
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
||||||
|
* caller may surface it.
|
||||||
|
*/
|
||||||
|
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
readonly close: () => Promise<void>;
|
readonly close: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pingStatement(statementTimeoutMs: number): string {
|
||||||
|
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
||||||
|
// parameters. Only a positive safe integer can ever reach the SQL text.
|
||||||
|
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
||||||
|
throw new RangeError("The ping statement timeout must be a positive integer.");
|
||||||
|
}
|
||||||
|
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
||||||
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||||
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()`
|
* `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`/
|
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||||
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||||
* for callers that only need one domain.
|
* for callers that only need one domain.
|
||||||
@@ -35,7 +72,14 @@ export function createApiDatabase(
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
authorization: createAuthorizationDrizzleView(pool),
|
authorization: createAuthorizationDrizzleView(pool),
|
||||||
close,
|
close,
|
||||||
|
devices: createDeviceDrizzleView(pool),
|
||||||
|
dashboard: createDashboardDrizzleView(pool),
|
||||||
identity: createIdentityDrizzleView(pool),
|
identity: createIdentityDrizzleView(pool),
|
||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
|
moduleConfiguration: createModuleConfigurationView(pool),
|
||||||
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
|
ping: async (statementTimeoutMs: number) => {
|
||||||
|
await pool.query(pingStatement(statementTimeoutMs));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { createDevicePrincipal, type EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||||
|
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||||
|
import { DEVICE_LAST_SEEN_THROTTLE_MS } from "@ekklesia/core/devices";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import { DEVICE_SESSION_COOKIE } from "./device-routes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends principal resolution with managed devices, without touching how a *person* is resolved
|
||||||
|
* (ADR 0036).
|
||||||
|
*
|
||||||
|
* Composition rather than modification is deliberate. Core's identity runtime is the Better Auth
|
||||||
|
* boundary and knows only about human sessions; teaching it about devices would put two unrelated
|
||||||
|
* credential systems behind one implementation and make "which one answered?" an internal detail.
|
||||||
|
* Wrapping keeps Better Auth's behaviour byte-for-byte unchanged — including that an infrastructure
|
||||||
|
* failure still throws rather than degrading to anonymous — and adds exactly one fallback.
|
||||||
|
*
|
||||||
|
* **A human session always wins.** If Better Auth resolves a person, that is the answer and the
|
||||||
|
* device cookie is never even read. This matters because a browser can hold both: an administrator
|
||||||
|
* who opens a display page on their own laptop after pairing it stays themself, and no device
|
||||||
|
* cookie can dilute or replace a human identity.
|
||||||
|
*
|
||||||
|
* Because every gate in the platform — the display page, the descriptor, module HTTP routes,
|
||||||
|
* realtime — resolves principals through this one function, a device becomes a principal in exactly
|
||||||
|
* one place, and every authorization decision about it is still made by the ordinary evaluator.
|
||||||
|
*/
|
||||||
|
export interface DevicePrincipalOptions {
|
||||||
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
|
readonly devices: DeviceStore;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 6265 `cookie-string` parsing, matching how Core's own session-cookie detection reads a
|
||||||
|
* header: split on `;`, take the text before the first `=` as the name, compare whole names
|
||||||
|
* exactly. Never a substring test, so `x_ekklesia_device_session` is correctly not a match. */
|
||||||
|
function readCookie(headers: Headers, name: string): string | undefined {
|
||||||
|
const header = headers.get("cookie");
|
||||||
|
if (header === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const pair of header.split(";")) {
|
||||||
|
const separator = pair.indexOf("=");
|
||||||
|
if (separator === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pair.slice(0, separator).trim() === name) {
|
||||||
|
const value = pair.slice(separator + 1).trim();
|
||||||
|
return value.length === 0 ? undefined : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withDevicePrincipals(options: DevicePrincipalOptions): EkklesiaIdentityRuntime {
|
||||||
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
handleAuthRequest: (request: Request): Promise<Response> =>
|
||||||
|
options.runtime.handleAuthRequest(request),
|
||||||
|
|
||||||
|
async resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal> {
|
||||||
|
// Unchanged, including its failure behaviour: a genuine outage still throws rather than
|
||||||
|
// silently letting a device credential answer a question about a person.
|
||||||
|
const human = await options.runtime.resolvePrincipal(headers);
|
||||||
|
if (human.kind !== "anonymous") {
|
||||||
|
return human;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = readCookie(headers, DEVICE_SESSION_COOKIE);
|
||||||
|
if (credential === undefined) {
|
||||||
|
return human;
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = now();
|
||||||
|
const device = await options.devices.resolveCredential(credential, at);
|
||||||
|
if (!device) {
|
||||||
|
// A revoked, expired, or simply wrong credential is anonymous — never an error the caller
|
||||||
|
// could use to tell those three apart.
|
||||||
|
return human;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttled here as well as in SQL: the common case must not even issue a statement, or a
|
||||||
|
// display polling realtime would turn an operational nicety into continuous write traffic.
|
||||||
|
const lastSeen = device.lastSeenAt;
|
||||||
|
if (
|
||||||
|
lastSeen === undefined ||
|
||||||
|
at.getTime() - lastSeen.getTime() >= DEVICE_LAST_SEEN_THROTTLE_MS
|
||||||
|
) {
|
||||||
|
await options.devices.touchLastSeen(device.deviceId, at).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDevicePrincipal(device.deviceId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
|
||||||
|
import { CORE_DEVICES_MANAGE } from "@ekklesia/core/authorization";
|
||||||
|
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||||
|
import { DEVICE_CREDENTIAL_TTL_MS } from "@ekklesia/core/devices";
|
||||||
|
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import { Hono as HonoApp } from "hono";
|
||||||
|
import { getCookie, setCookie } from "hono/cookie";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-owned device identity transport: pairing a display, and managing what has been paired
|
||||||
|
* (ADR 0036).
|
||||||
|
*
|
||||||
|
* Both prefixes are siblings of `/api/v1/modules/...` and cannot collide with any module route.
|
||||||
|
* Nothing here is reachable by module code, and no module-facing contract mentions any of it — a
|
||||||
|
* module receives a principal and its permissions, and never learns that pairing exists.
|
||||||
|
*/
|
||||||
|
export const DEVICE_PAIRING_PREFIX = "/api/v1/device-pairing";
|
||||||
|
export const DEVICES_PREFIX = "/api/v1/devices";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device's two cookies, deliberately named nothing like Better Auth's session cookie.
|
||||||
|
*
|
||||||
|
* Distinct names are what keep the two identity systems from being confused for one another: the
|
||||||
|
* human resolver looks only for Better Auth's cookie and the device resolver looks only for these,
|
||||||
|
* so neither can ever be satisfied by the other's credential no matter what a caller sends.
|
||||||
|
*/
|
||||||
|
export const DEVICE_PAIRING_COOKIE = "ekklesia_device_pairing";
|
||||||
|
export const DEVICE_SESSION_COOKIE = "ekklesia_device_session";
|
||||||
|
|
||||||
|
/** How often an unpaired screen should ask again. Slow enough that a waiting display is not a load
|
||||||
|
* source, fast enough that someone standing at the screen sees it come alive. */
|
||||||
|
export const DEVICE_PAIRING_POLL_INTERVAL_MS = 3_000;
|
||||||
|
|
||||||
|
export interface DeviceRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
readonly devices: DeviceStore;
|
||||||
|
/** The same eligible/running publication registry the display routes use. It is the only source
|
||||||
|
* of the permission a pairing request may ask for. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
/** True when the platform is reached over https, which is what `Secure` requires to be usable at
|
||||||
|
* all. Derived from the validated identity base URL, never from a request. */
|
||||||
|
readonly secureCookies: boolean;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noStore(c: Context): void {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
|
||||||
|
noStore(c);
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonObject(c: Context): Promise<Record<string, unknown> | undefined> {
|
||||||
|
try {
|
||||||
|
const body: unknown = await c.req.json();
|
||||||
|
return typeof body === "object" && body !== null && !Array.isArray(body)
|
||||||
|
? (body as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(body: Record<string, unknown>, key: string, max: number): string | undefined {
|
||||||
|
const value = body[key];
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length === 0 || trimmed.length > max ? undefined : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one of the device cookies.
|
||||||
|
*
|
||||||
|
* `HttpOnly` so no script — least of all a module's display bundle, which runs in this very page —
|
||||||
|
* can read the value; `SameSite=Strict` because a display only ever talks to the host that serves
|
||||||
|
* it and nothing legitimate arrives cross-site; host-only (no `Domain`) so a sibling hostname
|
||||||
|
* cannot claim it; `Path=/` because the credential is presented to the descriptor, the module API,
|
||||||
|
* and realtime alike. `Secure` follows the deployment's own scheme — asserting it on a plaintext
|
||||||
|
* development origin would make the cookie silently unusable rather than more secure.
|
||||||
|
*/
|
||||||
|
function writeDeviceCookie(
|
||||||
|
c: Context,
|
||||||
|
options: DeviceRoutesOptions,
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
maxAgeSeconds: number,
|
||||||
|
): void {
|
||||||
|
setCookie(c, name, value, {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: maxAgeSeconds,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "Strict",
|
||||||
|
secure: options.secureCookies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||||
|
const routes = new HonoApp();
|
||||||
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
const requireManage = requirePermission(options, CORE_DEVICES_MANAGE);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unpaired display asks to be paired.
|
||||||
|
*
|
||||||
|
* The **only** thing the caller supplies is which module's display it is sitting on, and even
|
||||||
|
* that is not taken at its word: the module is looked up in the publication registry and the
|
||||||
|
* permission is read from that module's validated display declaration. A browser therefore cannot
|
||||||
|
* ask to be granted `core.devices.manage`, another module's permission, or anything else — the
|
||||||
|
* request records the permission the host derived, and approval grants exactly that.
|
||||||
|
*/
|
||||||
|
routes.post(
|
||||||
|
`${DEVICE_PAIRING_PREFIX}/requests`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
async (c: Context) => {
|
||||||
|
const body = await readJsonObject(c);
|
||||||
|
const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128);
|
||||||
|
if (moduleId === undefined) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const display = options.moduleUi
|
||||||
|
.listDisplays()
|
||||||
|
.find((candidate) => candidate.moduleId === moduleId);
|
||||||
|
if (!display) {
|
||||||
|
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
|
||||||
|
// pairing never becomes a way to enumerate what is installed.
|
||||||
|
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const at = now();
|
||||||
|
const created = await options.devices.createPairingRequest({
|
||||||
|
moduleId: display.moduleId,
|
||||||
|
now: at,
|
||||||
|
permissionId: display.displayPermission,
|
||||||
|
});
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
options,
|
||||||
|
DEVICE_PAIRING_COOKIE,
|
||||||
|
created.claimSecret,
|
||||||
|
Math.max(1, Math.ceil((created.expiresAt.getTime() - at.getTime()) / 1000)),
|
||||||
|
);
|
||||||
|
noStore(c);
|
||||||
|
// The code is returned because it has to be shown on the screen. The claim secret is not:
|
||||||
|
// it goes only into the HttpOnly cookie above, so reading the code off a television tells
|
||||||
|
// an onlooker nothing they could use.
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
code: created.code,
|
||||||
|
expiresAt: created.expiresAt.toISOString(),
|
||||||
|
pollIntervalMs: DEVICE_PAIRING_POLL_INTERVAL_MS,
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display asks whether it has been approved yet, and collects its credential when it has.
|
||||||
|
*
|
||||||
|
* Authority here is the claim cookie and nothing else. The pairing code is not accepted — it is
|
||||||
|
* public, printed across a screen, and a flow that issued a credential to whoever presented it
|
||||||
|
* would hand the device's identity to anyone who walked past. Consumption is single-use inside
|
||||||
|
* the store, so a replayed or concurrent claim never yields a second credential.
|
||||||
|
*/
|
||||||
|
routes.post(
|
||||||
|
`${DEVICE_PAIRING_PREFIX}/claims`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
async (c: Context) => {
|
||||||
|
const claimSecret = getCookie(c, DEVICE_PAIRING_COOKIE);
|
||||||
|
if (claimSecret === undefined || claimSecret.length === 0) {
|
||||||
|
return sanitizedFailure(c, 400, "DEVICE_PAIRING_UNKNOWN");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const at = now();
|
||||||
|
const result = await options.devices.claimPairingRequest({ claimSecret, now: at });
|
||||||
|
noStore(c);
|
||||||
|
if (result.paired) {
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
options,
|
||||||
|
DEVICE_SESSION_COOKIE,
|
||||||
|
result.paired.credential,
|
||||||
|
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||||
|
);
|
||||||
|
// The pairing cookie has done its job; leaving it would keep a spent secret on the device.
|
||||||
|
writeDeviceCookie(c, options, DEVICE_PAIRING_COOKIE, "", 0);
|
||||||
|
// Deliberately no credential, device id, or permission in the body: the browser needs to
|
||||||
|
// know only that it may now load the display, and everything it needs travels in the
|
||||||
|
// Set-Cookie header where script cannot reach it.
|
||||||
|
return c.json({ status: "paired" as const }, 200);
|
||||||
|
}
|
||||||
|
return c.json({ status: result.status }, 200);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An administrator types the code from the screen and sees what it is asking for, before deciding.
|
||||||
|
*
|
||||||
|
* The code travels in a request body rather than a path or query so it does not land in access
|
||||||
|
* logs, browser history, or a `Referer`. Reaching this at all requires `core.devices.manage`, so
|
||||||
|
* it is not an enumeration oracle for anyone who is not already trusted to approve pairings.
|
||||||
|
*/
|
||||||
|
routes.post(
|
||||||
|
`${DEVICE_PAIRING_PREFIX}/lookups`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c: Context) => {
|
||||||
|
const body = await readJsonObject(c);
|
||||||
|
const code = body === undefined ? undefined : readString(body, "code", 16);
|
||||||
|
if (code === undefined || !/^[0-9]{6}$/u.test(code)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const found = await options.devices.findPendingByCode(code, now());
|
||||||
|
if (!found) {
|
||||||
|
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
|
||||||
|
}
|
||||||
|
noStore(c);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
expiresAt: found.expiresAt.toISOString(),
|
||||||
|
moduleId: found.moduleId,
|
||||||
|
permissionId: found.permissionId,
|
||||||
|
requestedAt: found.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Approval. The administrator names the device; the unpaired screen never names itself. */
|
||||||
|
routes.post(
|
||||||
|
`${DEVICE_PAIRING_PREFIX}/approvals`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c: Context) => {
|
||||||
|
const body = await readJsonObject(c);
|
||||||
|
const code = body === undefined ? undefined : readString(body, "code", 16);
|
||||||
|
const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128);
|
||||||
|
if (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === undefined) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
if (principal.kind !== "authenticated") {
|
||||||
|
// Unreachable through `requireManage`, which only ever admits a person. Stated rather than
|
||||||
|
// assumed, so a device could never be recorded as having approved its own pairing.
|
||||||
|
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const approved = await options.devices.approvePairingRequest({
|
||||||
|
approvedBy: principal.userId,
|
||||||
|
code,
|
||||||
|
deviceName,
|
||||||
|
now: now(),
|
||||||
|
});
|
||||||
|
if (!approved) {
|
||||||
|
// Expired, already approved, already consumed, or never existed. One answer for all of
|
||||||
|
// them: which one it was is not information this endpoint owes anybody.
|
||||||
|
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
|
||||||
|
}
|
||||||
|
noStore(c);
|
||||||
|
return c.json({ moduleId: approved.moduleId, permissionId: approved.permissionId }, 200);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
|
||||||
|
try {
|
||||||
|
const devices = await options.devices.listDevices();
|
||||||
|
noStore(c);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
devices: devices.map((device) => ({
|
||||||
|
createdAt: device.createdAt.toISOString(),
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
permissions: device.permissions,
|
||||||
|
revoked: device.revokedAt !== undefined,
|
||||||
|
...(device.lastSeenAt === undefined
|
||||||
|
? {}
|
||||||
|
: { lastSeenAt: device.lastSeenAt.toISOString() }),
|
||||||
|
...(device.revokedAt === undefined
|
||||||
|
? {}
|
||||||
|
: { revokedAt: device.revokedAt.toISOString() }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revocation, which is a server-side fact rather than a request that the remote browser discard
|
||||||
|
* anything. The device's own copy of its credential keeps existing and stops working.
|
||||||
|
*/
|
||||||
|
routes.post(
|
||||||
|
`${DEVICES_PREFIX}/:deviceId/revoke`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c: Context) => {
|
||||||
|
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
if (principal.kind !== "authenticated") {
|
||||||
|
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.devices.revokeDevice({
|
||||||
|
deviceId: parsed.data,
|
||||||
|
now: now(),
|
||||||
|
revokedBy: principal.userId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return sanitizedFailure(c, 404, "NOT_FOUND");
|
||||||
|
}
|
||||||
|
noStore(c);
|
||||||
|
return c.body(null, 204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||||
|
*
|
||||||
|
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||||
|
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||||
|
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
|
||||||
|
* genuinely immutable value that every response can reuse safely.
|
||||||
|
*/
|
||||||
|
export interface DisplayHostAssets {
|
||||||
|
readonly html: string;
|
||||||
|
readonly script: string;
|
||||||
|
readonly scriptEtag: string;
|
||||||
|
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
|
||||||
|
* at a path whose directory is its scope so it controls display pages and nothing else. */
|
||||||
|
readonly serviceWorker: string;
|
||||||
|
readonly serviceWorkerEtag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
|
* location, and machine path are intentionally not retained or surfaced. */
|
||||||
|
export class DisplayHostAssetError extends Error {
|
||||||
|
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("The display host browser assets are unavailable.");
|
||||||
|
this.name = "DisplayHostAssetError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures one immutable, internally consistent display-host asset snapshot.
|
||||||
|
*
|
||||||
|
* The strong validator is always derived from the exact script text in the same snapshot; callers
|
||||||
|
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||||
|
* failed host build rather than a valid page that can never mount anything.
|
||||||
|
*/
|
||||||
|
export function createDisplayHostAssets(
|
||||||
|
html: string,
|
||||||
|
script: string,
|
||||||
|
serviceWorker: string,
|
||||||
|
): DisplayHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||||
|
throw new DisplayHostAssetError();
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
html,
|
||||||
|
script,
|
||||||
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
|
||||||
|
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||||
|
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||||
|
try {
|
||||||
|
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
|
||||||
|
// rejected sibling promise is left running after this function has already failed.
|
||||||
|
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||||
|
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||||
|
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
||||||
|
return createDisplayHostAssets(html, script, serviceWorker);
|
||||||
|
} catch {
|
||||||
|
throw new DisplayHostAssetError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short
|
||||||
|
* enough for a Compose health check or an external monitor to treat a stall as "not ready" rather
|
||||||
|
* than as a hung probe, and well inside the shared pool's own 10s connection and operation
|
||||||
|
* timeouts, which bound the underlying query independently.
|
||||||
|
*/
|
||||||
|
export const READINESS_DATABASE_TIMEOUT_MS = 2_000;
|
||||||
|
|
||||||
|
export const HEALTH_LIVE_PATH = "/api/v1/health/live";
|
||||||
|
export const HEALTH_READY_PATH = "/api/v1/health/ready";
|
||||||
|
|
||||||
|
export interface ApiHealthOptions {
|
||||||
|
/**
|
||||||
|
* One trivial database round trip. Resolves when the database answered; rejects on any failure.
|
||||||
|
* The rejection is never read — not its message, code, or cause — so nothing a driver says can
|
||||||
|
* reach a response. The route bounds the wait and never overlaps two calls; this function only
|
||||||
|
* has to perform the query.
|
||||||
|
*/
|
||||||
|
readonly checkDatabase: () => Promise<void>;
|
||||||
|
/** Whether module startup has completed. `false` again from the moment shutdown begins, so a
|
||||||
|
* draining instance stops reporting ready while it finishes in-flight requests. */
|
||||||
|
readonly isModuleStartupComplete: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIVE_BODY = Object.freeze({ status: "live" });
|
||||||
|
const READY_BODY = Object.freeze({ status: "ready" });
|
||||||
|
const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than
|
||||||
|
* `timeoutMs`.
|
||||||
|
*
|
||||||
|
* Every probe arriving while a check is unsettled shares that check's outcome, including after its
|
||||||
|
* deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes
|
||||||
|
* answer immediately. A new check starts only once the previous call has actually settled. A timed-
|
||||||
|
* out query still occupies a pool connection until the database or the pool's own timeouts end it,
|
||||||
|
* so starting a fresh query on every probe would let a stalled database drain the pool one health
|
||||||
|
* check at a time.
|
||||||
|
*/
|
||||||
|
function createBoundedDatabaseCheck(
|
||||||
|
checkDatabase: () => Promise<void>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): () => Promise<boolean> {
|
||||||
|
let inFlight: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (inFlight !== undefined) {
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like
|
||||||
|
// any other rather than an exception escaping the route.
|
||||||
|
const settled = new Promise<void>((resolve) => resolve(checkDatabase())).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const outcome = Promise.race([
|
||||||
|
settled,
|
||||||
|
new Promise<false>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
inFlight = outcome;
|
||||||
|
void settled.then(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
inFlight = undefined;
|
||||||
|
});
|
||||||
|
return outcome;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD,
|
||||||
|
* and these routes accept GET alone. */
|
||||||
|
function methodNotAllowed(): Response {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
||||||
|
status: 405,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external
|
||||||
|
* monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies
|
||||||
|
* that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed
|
||||||
|
* operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that
|
||||||
|
* installed modules own entirely, so no module route can shadow them.
|
||||||
|
*/
|
||||||
|
export function createHealthRoutes(options: ApiHealthOptions): Hono {
|
||||||
|
const checkDatabase = createBoundedDatabaseCheck(
|
||||||
|
options.checkDatabase,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.all(HEALTH_LIVE_PATH, (c) => {
|
||||||
|
if (c.req.method !== "GET") {
|
||||||
|
return methodNotAllowed();
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json(LIVE_BODY, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.all(HEALTH_READY_PATH, async (c) => {
|
||||||
|
if (c.req.method !== "GET") {
|
||||||
|
return methodNotAllowed();
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
if (!options.isModuleStartupComplete()) {
|
||||||
|
return c.json(NOT_READY_BODY, 503);
|
||||||
|
}
|
||||||
|
const databaseReady = await checkDatabase();
|
||||||
|
// Checked again afterwards: shutdown may have begun while the query was in flight.
|
||||||
|
return databaseReady && options.isModuleStartupComplete()
|
||||||
|
? c.json(READY_BODY, 200)
|
||||||
|
: c.json(NOT_READY_BODY, 503);
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import {
|
||||||
|
type ModuleConfigurationDeclaration,
|
||||||
|
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
|
||||||
|
moduleConfigurationKeySchema,
|
||||||
|
moduleIdSchema,
|
||||||
|
type ModuleSecretSource,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { CORE_MODULES_CONFIGURATION_MANAGE } from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
type DiscoveredModule,
|
||||||
|
type ModuleConfigurationSettings,
|
||||||
|
type ModuleConfigurationStore,
|
||||||
|
ModuleConfigurationStoreError,
|
||||||
|
resolveModuleConfigurationFor,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **sibling** of `/api/v1/modules/`, never a child — the third application of the rule ADR 0025
|
||||||
|
* established and ADR 0028 reapplied. `config` and `configuration` are both ordinary lowercase
|
||||||
|
* static segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately
|
||||||
|
* register `PUT /config` and land on exactly `/api/v1/modules/announcements/config`. Publishing the
|
||||||
|
* platform's own configuration mutation there would put Core and an installed module in one URL
|
||||||
|
* namespace with registration order deciding which one answers — for a route that changes how
|
||||||
|
* modules behave.
|
||||||
|
*/
|
||||||
|
export const MODULE_CONFIGURATION_PATH = "/api/v1/module-configuration";
|
||||||
|
const MODULE_CONFIGURATION_MODULE_PATH = `${MODULE_CONFIGURATION_PATH}/:moduleId`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One declared key as an operator sees it: the module's own declaration, plus what is currently
|
||||||
|
* stored, plus what the running process actually used.
|
||||||
|
*
|
||||||
|
* `value` is **absent for every `secret` key, always**. Not null, not redacted, not a placeholder —
|
||||||
|
* absent, because there is no code path that could put secret material here: Ekklesia never persists
|
||||||
|
* it, and the resolver's snapshot is never serialized. `configured` reports whether the deployment
|
||||||
|
* supplied one, which is the only thing an operator needs to know from here.
|
||||||
|
*/
|
||||||
|
export interface ModuleConfigurationKeyView {
|
||||||
|
readonly key: string;
|
||||||
|
readonly type: ModuleConfigurationDeclaration["type"];
|
||||||
|
readonly required: boolean;
|
||||||
|
readonly description?: string;
|
||||||
|
/** The declared default, for non-secret keys that have one. Part of the module's public manifest. */
|
||||||
|
readonly default?: string | number | boolean;
|
||||||
|
/** Present only for `type: "enum"`. */
|
||||||
|
readonly values?: readonly string[];
|
||||||
|
/** The stored value, when one is stored and the key is not a secret. Absent means "no value
|
||||||
|
* stored" — the default applies, or the key is unset. */
|
||||||
|
readonly value?: string;
|
||||||
|
/** For a secret: whether the deployment supplies it. For anything else: whether a value is stored.
|
||||||
|
* Never implies the value is valid — see the module's `runtime` entry for that. */
|
||||||
|
readonly configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleConfigurationView {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly keys: readonly ModuleConfigurationKeyView[];
|
||||||
|
/** Stored keys this module's current manifest no longer declares. Retained rather than deleted, so
|
||||||
|
* a downgrade or a rename does not destroy an operator's value, and surfaced here so it is visible
|
||||||
|
* rather than merely tolerated. */
|
||||||
|
readonly unknownKeys: readonly string[];
|
||||||
|
/** `true` when the stored configuration no longer matches what this process resolved at boot. A
|
||||||
|
* configuration change is applied by the next restart; nothing is hot-reloaded (ADR 0029). */
|
||||||
|
readonly restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModuleConfigurationRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The discovered inventory this process booted from — the source of every declaration this route
|
||||||
|
* validates against. Never re-discovered: a manifest that changed on disk since boot is not what
|
||||||
|
* the running platform is using. */
|
||||||
|
readonly discoveredModules: readonly DiscoveredModule[];
|
||||||
|
/** The stored settings this process resolved at boot, used only to compute `restartRequired` by
|
||||||
|
* comparison with what is stored now. */
|
||||||
|
readonly bootSettings: ModuleConfigurationSettings;
|
||||||
|
readonly secrets: ModuleSecretSource;
|
||||||
|
readonly store: ModuleConfigurationStore;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. The store's
|
||||||
|
* errors never carry a value, so nothing here can leak one either. */
|
||||||
|
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
|
||||||
|
if (error instanceof ModuleConfigurationStoreError) {
|
||||||
|
if (
|
||||||
|
error.code === "MODULE_CONFIGURATION_READ_FAILED" ||
|
||||||
|
error.code === "MODULE_CONFIGURATION_WRITE_FAILED"
|
||||||
|
) {
|
||||||
|
return sanitizedFailure(c, 503, error.code);
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
return sanitizedFailure(
|
||||||
|
c,
|
||||||
|
503,
|
||||||
|
fallback === "read" ? "MODULE_CONFIGURATION_READ_FAILED" : "MODULE_CONFIGURATION_WRITE_FAILED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BODY_KEYS = new Set(["values"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical text form of a submitted value, or `undefined` when it is not one this key accepts.
|
||||||
|
*
|
||||||
|
* Accepts JSON's own types — a number for `integer`, a boolean for `boolean`, a string for the rest
|
||||||
|
* — and converts to the single canonical text form the store persists and the resolver parses. It
|
||||||
|
* never accepts a string for a number (`"7"`), so exactly one spelling reaches storage and a stored
|
||||||
|
* row can never validate differently from the request that created it.
|
||||||
|
*/
|
||||||
|
function canonicalStoredValue(
|
||||||
|
declaration: ModuleConfigurationDeclaration,
|
||||||
|
submitted: unknown,
|
||||||
|
): string | undefined {
|
||||||
|
switch (declaration.type) {
|
||||||
|
case "integer": {
|
||||||
|
return typeof submitted === "number" && Number.isSafeInteger(submitted)
|
||||||
|
? String(submitted)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
case "boolean": {
|
||||||
|
return typeof submitted === "boolean" ? String(submitted) : undefined;
|
||||||
|
}
|
||||||
|
case "string":
|
||||||
|
case "enum":
|
||||||
|
case "url": {
|
||||||
|
return typeof submitted === "string" &&
|
||||||
|
submitted.length <= MODULE_CONFIGURATION_VALUE_MAX_LENGTH
|
||||||
|
? submitted
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
case "secret": {
|
||||||
|
// Unreachable: a secret key is rejected before this is called. The branch exists so adding a
|
||||||
|
// type to the union is a compile error rather than a silent acceptance of secret material.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(c: Context): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await c.req.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function declarationView(
|
||||||
|
declaration: ModuleConfigurationDeclaration,
|
||||||
|
storedValue: string | undefined,
|
||||||
|
secretConfigured: boolean,
|
||||||
|
): ModuleConfigurationKeyView {
|
||||||
|
const isSecret = declaration.type === "secret";
|
||||||
|
return {
|
||||||
|
// A secret is `configured` when the deployment supplies it; anything else when a value is
|
||||||
|
// stored. The two are different questions with one honest answer each.
|
||||||
|
configured: isSecret ? secretConfigured : storedValue !== undefined,
|
||||||
|
key: declaration.key,
|
||||||
|
required: declaration.required,
|
||||||
|
type: declaration.type,
|
||||||
|
...(declaration.description === undefined ? {} : { description: declaration.description }),
|
||||||
|
...(isSecret || declaration.default === undefined ? {} : { default: declaration.default }),
|
||||||
|
...(declaration.type === "enum" ? { values: declaration.values } : {}),
|
||||||
|
// The one place a stored value reaches a response — and structurally never for a secret.
|
||||||
|
...(isSecret || storedValue === undefined ? {} : { value: storedValue }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's module configuration resource: what each installed module declares it needs, what
|
||||||
|
* an operator has stored, and whether the running process is still using it.
|
||||||
|
*
|
||||||
|
* ## Secrets are absent, not filtered
|
||||||
|
*
|
||||||
|
* There is no branch here that redacts a secret, because there is nothing to redact: Ekklesia never
|
||||||
|
* persists secret material, so the store this route reads from contains none. A `secret` key's
|
||||||
|
* `value` field is omitted by construction and `configured` is computed by asking the deployment's
|
||||||
|
* secret source a yes/no question. A future code change that tried to serve a secret value would
|
||||||
|
* have to first invent somewhere to read one from.
|
||||||
|
*
|
||||||
|
* ## Changing configuration does not change the running process
|
||||||
|
*
|
||||||
|
* `PUT` replaces the stored document. It does not restart, reload, or reconfigure anything: modules
|
||||||
|
* receive a frozen snapshot resolved once at startup (ADR 0029), so the response reports
|
||||||
|
* `restartRequired` rather than pretending the change took effect.
|
||||||
|
*
|
||||||
|
* ## Closed validation against the module's own declaration
|
||||||
|
*
|
||||||
|
* A submitted key the module does not declare is rejected, as is a value of the wrong JSON type or a
|
||||||
|
* value failing the module's declared constraints. Nothing is coerced, truncated, or dropped:
|
||||||
|
* `{"slideSeconds": "7"}` is a `400`, not a silent `7`. A secret key is rejected outright — there is
|
||||||
|
* no supported way to submit secret material to this platform.
|
||||||
|
*/
|
||||||
|
export function createModuleConfigurationRoutes(
|
||||||
|
options: CreateModuleConfigurationRoutesOptions,
|
||||||
|
): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const requireManage = requirePermission(
|
||||||
|
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||||
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
|
);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
const modulesById = new Map(
|
||||||
|
options.discoveredModules.map((module) => [module.manifest.id, module] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
const storedFor = (
|
||||||
|
settings: ModuleConfigurationSettings,
|
||||||
|
moduleId: string,
|
||||||
|
): ReadonlyMap<string, string> =>
|
||||||
|
new Map(
|
||||||
|
settings.records
|
||||||
|
.filter((record) => record.moduleId === moduleId)
|
||||||
|
.map((record) => [record.key, record.value] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Two documents differ when the exact set of (key, value) pairs differs. Compared against the
|
||||||
|
* settings this process actually resolved at boot, so `restartRequired` is an observation rather
|
||||||
|
* than a guess. */
|
||||||
|
const differsFromBoot = (moduleId: string, current: ReadonlyMap<string, string>): boolean => {
|
||||||
|
const boot = storedFor(options.bootSettings, moduleId);
|
||||||
|
if (boot.size !== current.size) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const [key, value] of current) {
|
||||||
|
if (boot.get(key) !== value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildView = (
|
||||||
|
module: DiscoveredModule,
|
||||||
|
settings: ModuleConfigurationSettings,
|
||||||
|
): ModuleConfigurationView => {
|
||||||
|
const stored = storedFor(settings, module.manifest.id);
|
||||||
|
const resolved = resolveModuleConfigurationFor(module.manifest, {
|
||||||
|
secrets: options.secrets,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
keys: module.manifest.configuration.map((declaration) =>
|
||||||
|
declarationView(
|
||||||
|
declaration as ModuleConfigurationDeclaration,
|
||||||
|
stored.get(declaration.key),
|
||||||
|
// Asked, never read into the response: the answer is a boolean, and the value it was
|
||||||
|
// derived from never leaves the resolver.
|
||||||
|
declaration.type === "secret"
|
||||||
|
? options.secrets.get(module.manifest.id, declaration.key) !== undefined
|
||||||
|
: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
moduleId: module.manifest.id,
|
||||||
|
restartRequired: differsFromBoot(module.manifest.id, stored),
|
||||||
|
unknownKeys: resolved.unknownKeys,
|
||||||
|
version: module.manifest.version,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
routes.get(MODULE_CONFIGURATION_MODULE_PATH, requireManage, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
|
||||||
|
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
|
||||||
|
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return c.json(buildView(module, await options.store.readSettings()), 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error, "read");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
MODULE_CONFIGURATION_MODULE_PATH,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const raw = await readJsonBody(c);
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !BODY_KEYS.has(key))) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const submitted = raw.values;
|
||||||
|
if (!isRecord(submitted)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
|
||||||
|
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
|
||||||
|
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarations = new Map(
|
||||||
|
module.manifest.configuration.map(
|
||||||
|
(declaration) =>
|
||||||
|
[declaration.key, declaration as ModuleConfigurationDeclaration] as const,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const values: { key: string; value: string }[] = [];
|
||||||
|
// `Object.keys` on a parsed JSON object never yields inherited properties, and every key is
|
||||||
|
// re-validated against the platform's one configuration-key grammar before it is used, so a
|
||||||
|
// body carrying `__proto__` or `constructor` is a plain `400` rather than anything stranger.
|
||||||
|
for (const key of Object.keys(submitted)) {
|
||||||
|
if (!moduleConfigurationKeySchema.safeParse(key).success) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
const declaration = declarations.get(key);
|
||||||
|
if (!declaration) {
|
||||||
|
return sanitizedFailure(c, 400, "CONFIGURATION_KEY_UNDECLARED");
|
||||||
|
}
|
||||||
|
if (declaration.type === "secret") {
|
||||||
|
// Deliberate and permanent for this pass: Ekklesia has no encryption at rest, so there is
|
||||||
|
// no honest way to accept secret material over HTTP and store it. Secrets are supplied by
|
||||||
|
// the deployment environment (ADR 0029).
|
||||||
|
return sanitizedFailure(c, 400, "CONFIGURATION_SECRET_NOT_WRITABLE");
|
||||||
|
}
|
||||||
|
const canonical = canonicalStoredValue(declaration, submitted[key]);
|
||||||
|
if (canonical === undefined) {
|
||||||
|
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
|
||||||
|
}
|
||||||
|
values.push({ key, value: canonical });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraint validation runs through the **same resolver the platform boots with**, over the
|
||||||
|
// exact document about to be stored, rather than a second copy of the type rules here. That
|
||||||
|
// is what guarantees the API and startup can never disagree about whether a value is valid:
|
||||||
|
// there is one implementation, and this is it.
|
||||||
|
//
|
||||||
|
// An *incomplete* document is accepted deliberately — an operator may save partial work, and
|
||||||
|
// the module is then reported `blocked · configuration_missing` until they finish. An
|
||||||
|
// *invalid* one never is: storing a value that could not survive the next boot would hand
|
||||||
|
// back a success for a change that silently breaks the module.
|
||||||
|
const candidate = resolveModuleConfigurationFor(module.manifest, {
|
||||||
|
secrets: options.secrets,
|
||||||
|
settings: {
|
||||||
|
records: values.map((entry) => ({ ...entry, moduleId: module.manifest.id })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (candidate.issue?.reason === "configuration_invalid") {
|
||||||
|
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await options.store.replace({ moduleId: module.manifest.id, values });
|
||||||
|
// Re-read rather than predict, so this response and a later GET can never disagree.
|
||||||
|
return c.json(buildView(module, await options.store.readSettings()), 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error, "write");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Canonical browser routes owned by the platform's display host.
|
||||||
|
*
|
||||||
|
* Kept separate from the route registration module so both the display host and module-UI
|
||||||
|
* inventory can publish the same path without introducing a dependency cycle.
|
||||||
|
*/
|
||||||
|
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||||
|
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||||
|
|
||||||
|
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
|
||||||
|
import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules";
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
|
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
export {
|
||||||
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||||
|
MODULE_DISPLAY_PAGE_PREFIX,
|
||||||
|
moduleDisplayDescriptorUrl,
|
||||||
|
moduleDisplayPageUrl,
|
||||||
|
} from "./module-display-path.js";
|
||||||
|
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
|
||||||
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
|
import {
|
||||||
|
describeModuleContributions,
|
||||||
|
grantedModulePermissionIds,
|
||||||
|
type HostModuleContributionDescriptor,
|
||||||
|
} from "./module-ui-inventory.js";
|
||||||
|
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||||
|
|
||||||
|
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||||
|
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
|
||||||
|
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||||
|
/**
|
||||||
|
* Where the display's offline service worker is served (ADR 0043).
|
||||||
|
*
|
||||||
|
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
|
||||||
|
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
|
||||||
|
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
|
||||||
|
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||||
|
*
|
||||||
|
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
|
||||||
|
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
|
||||||
|
|
||||||
|
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
|
||||||
|
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
|
||||||
|
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
|
||||||
|
* boundary. */
|
||||||
|
export interface HostModuleDisplayDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly display: { readonly url: string };
|
||||||
|
/** The public page a screen opens to mount this display. This is distinct from `display.url`,
|
||||||
|
* which is the compiled artifact imported by the platform host. */
|
||||||
|
readonly page: { readonly url: string };
|
||||||
|
readonly api: { readonly baseUrl: string };
|
||||||
|
readonly realtime: { readonly baseUrl: string };
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
|
||||||
|
* mechanism a signage-style display uses to show another module's content without importing it. */
|
||||||
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who the host resolved as the viewer of this display.
|
||||||
|
*
|
||||||
|
* The descriptor reports it because the display host must not ask `/api/v1/principal` — that
|
||||||
|
* endpoint answers a question about *people*, and a paired device is deliberately anonymous to it
|
||||||
|
* (ADR 0036). Reading the viewer from the response that already performed the authorization means
|
||||||
|
* the display never has to infer "no human session, so presumably a device", and the two identity
|
||||||
|
* classes never have to be told apart by elimination.
|
||||||
|
*/
|
||||||
|
export type HostDisplayViewer =
|
||||||
|
| { readonly kind: "authenticated"; readonly userId: string }
|
||||||
|
| { readonly kind: "device"; readonly deviceId: string };
|
||||||
|
|
||||||
|
export interface HostModuleDisplayResource {
|
||||||
|
readonly module: HostModuleDisplayDescriptor;
|
||||||
|
readonly viewer: HostDisplayViewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The same immutable, eligible/running publication registry used for artifact bytes. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||||
|
* surface-owned files before binding and supplies their frozen contents here. */
|
||||||
|
readonly assets?: DisplayHostAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||||
|
* permission id, path, or package location. */
|
||||||
|
export class ModuleDisplayConfigurationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("The module display host configuration is inconsistent.");
|
||||||
|
this.name = "ModuleDisplayConfigurationError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayFailure(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||||
|
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||||
|
* bytes with a stale validator. */
|
||||||
|
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||||
|
try {
|
||||||
|
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||||
|
if (
|
||||||
|
snapshot.scriptEtag !== input.scriptEtag ||
|
||||||
|
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||||
|
) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeViewer(principal: EkklesiaPrincipal): HostDisplayViewer | undefined {
|
||||||
|
if (principal.kind === "authenticated") {
|
||||||
|
return { kind: "authenticated", userId: principal.userId };
|
||||||
|
}
|
||||||
|
if (principal.kind === "device") {
|
||||||
|
return { deviceId: principal.deviceId, kind: "device" };
|
||||||
|
}
|
||||||
|
// Unreachable behind the permission gate: the display permission is `restricted`, which no
|
||||||
|
// anonymous principal satisfies. Stated rather than assumed.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeDisplay(
|
||||||
|
options: ModuleDisplayRoutesOptions,
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
display: PublishableModuleDisplay,
|
||||||
|
viewer: HostDisplayViewer,
|
||||||
|
): Promise<HostModuleDisplayResource> {
|
||||||
|
return {
|
||||||
|
viewer,
|
||||||
|
module: {
|
||||||
|
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||||
|
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||||
|
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||||
|
id: display.moduleId,
|
||||||
|
page: { url: moduleDisplayPageUrl(display.moduleId) },
|
||||||
|
permissions: await grantedModulePermissionIds(
|
||||||
|
options.evaluator,
|
||||||
|
principal,
|
||||||
|
display.permissions,
|
||||||
|
),
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) },
|
||||||
|
version: display.version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountDisplay(
|
||||||
|
app: Hono,
|
||||||
|
options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets },
|
||||||
|
display: PublishableModuleDisplay,
|
||||||
|
): void {
|
||||||
|
// The permission comes from an already-validated manifest/publication record. A request selects
|
||||||
|
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
|
||||||
|
const requireDisplayPermission = requirePermission(options, display.displayPermission);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page itself is **not** permission-gated, and the descriptor below still is.
|
||||||
|
*
|
||||||
|
* An unattended screen has no identity until someone pairs it, so a gate here would mean a new
|
||||||
|
* television could only ever be told 403 — there would be nowhere for it to display a pairing
|
||||||
|
* code, and the only way to adopt one would be to sign a human into it, which is exactly the
|
||||||
|
* model this pass exists to remove (ADR 0036).
|
||||||
|
*
|
||||||
|
* Serving it costs nothing, because it carries nothing: these are the same inert, module-agnostic
|
||||||
|
* bytes for every display, with no module id, no data, and one absolute script URL that was
|
||||||
|
* already public. Authorization lives entirely in the descriptor — the page can render, and then
|
||||||
|
* learns it may show only a pairing code. What this does reveal is that a module with a display
|
||||||
|
* exists at that path, which any pairing flow must reveal to the device it is pairing.
|
||||||
|
*/
|
||||||
|
app.get(moduleDisplayPageUrl(display.moduleId), () => {
|
||||||
|
return new Response(options.assets.html, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
try {
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
const viewer = describeViewer(principal);
|
||||||
|
if (!viewer) {
|
||||||
|
return displayFailure(c);
|
||||||
|
}
|
||||||
|
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||||
|
} catch {
|
||||||
|
return displayFailure(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts one stable page and one descriptor route per eligible, running display declaration.
|
||||||
|
*
|
||||||
|
* There is deliberately no display inventory in this pass. A management UI may need one later,
|
||||||
|
* but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only,
|
||||||
|
* disabled, and dependency-blocked modules have no registered route and collapse into the
|
||||||
|
* application's ordinary sanitized 404.
|
||||||
|
*
|
||||||
|
* The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute
|
||||||
|
* script URL cannot be rebased by a deep `/display/<id>` path. The browser reads the validated id
|
||||||
|
* from its outer URL, requests the permission-gated descriptor, and only then imports the exact
|
||||||
|
* versioned display artifact that the server supplied.
|
||||||
|
*/
|
||||||
|
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
|
||||||
|
const displays = options.moduleUi.listDisplays();
|
||||||
|
if (displays.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!options.assets) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
const assets = snapshotAssets(options.assets);
|
||||||
|
for (const display of displays) {
|
||||||
|
if (!options.registry.has(display.displayPermission)) {
|
||||||
|
throw new ModuleDisplayConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One fixed host bundle, never a directory and never a request-selected package file. Like module
|
||||||
|
// artifact bytes, fetching this platform code grants no authority; the page and descriptor are
|
||||||
|
// where identity and the module-owned display permission are enforced.
|
||||||
|
app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => {
|
||||||
|
if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(assets.script, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/javascript; charset=utf-8",
|
||||||
|
ETag: assets.scriptEtag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
|
||||||
|
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
|
||||||
|
* how a screen avoids being trapped on an obsolete worker.
|
||||||
|
*
|
||||||
|
* Like the host script and module artifact bytes, fetching this code grants no authority. It
|
||||||
|
* caches only public code and never a permission decision, so serving it anonymously withholds
|
||||||
|
* nothing the platform was protecting.
|
||||||
|
*/
|
||||||
|
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
|
||||||
|
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(assets.serviceWorker, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/javascript; charset=utf-8",
|
||||||
|
ETag: assets.serviceWorkerEtag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const display of displays) {
|
||||||
|
mountDisplay(app, { ...options, assets }, display);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import type {
|
import {
|
||||||
ModuleId,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
ModuleRouteHandlerContext,
|
EMPTY_MODULE_STORAGE,
|
||||||
ModuleRuntimeServices,
|
type ModuleId,
|
||||||
RegisteredModuleHttpRoute,
|
type ModuleRouteHandlerContext,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import type {
|
||||||
|
ModuleConfigurationLookup,
|
||||||
|
ModuleRealtimeRegistry,
|
||||||
|
ModuleStorageLookup,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
type AuthorizationMiddlewareOptions,
|
type AuthorizationMiddlewareOptions,
|
||||||
@@ -31,6 +38,27 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
|||||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||||
* after authorization succeeds. */
|
* after authorization succeeds. */
|
||||||
readonly services: ModuleRuntimeServices;
|
readonly services: ModuleRuntimeServices;
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Unlike `services`, which is one shared object, 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
|
/** Never carries the offending permission id, route path, module id, or any other configuration
|
||||||
@@ -45,13 +73,30 @@ export class ModuleRouteConfigurationError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The root a module's own HTTP API is mounted at — the one place that URL shape is constructed.
|
||||||
|
*
|
||||||
|
* Exported because a browser host has to resolve `context.api.request("records")`
|
||||||
|
* (`ModuleUiApiClient`, `@ekklesia/module-ui-sdk`) to a real Ekklesia URL, and a module UI must
|
||||||
|
* never build that prefix itself: it is Ekklesia's private deployment detail, and a module that
|
||||||
|
* hardcoded it could not be hosted anywhere else (ADR 0022). The server hands the resolved base to
|
||||||
|
* the host through the module UI inventory (`./module-ui-inventory.js`) rather than letting the
|
||||||
|
* browser re-spell this template, so this prefix can change without touching either.
|
||||||
|
*
|
||||||
|
* Percent-encoded for the same reason `moduleUiArtifactUrl` is: a value that somehow did not come
|
||||||
|
* from a validated manifest can only ever become one segment's worth of text, never a new segment.
|
||||||
|
* A real module id (lowercase kebab-case, `moduleIdSchema`) is unaffected by the encoding.
|
||||||
|
*/
|
||||||
|
export function moduleApiBaseUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_ROUTE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** "/" is the module's own root and maps to the bare module prefix, never a required trailing
|
/** "/" 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
|
* 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. */
|
* `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */
|
||||||
function moduleRoutePath(moduleId: ModuleId, routePath: string): string {
|
function moduleRoutePath(moduleId: ModuleId, routePath: string): string {
|
||||||
return routePath === "/"
|
const base = moduleApiBaseUrl(moduleId);
|
||||||
? `${MODULE_ROUTE_PREFIX}/${moduleId}`
|
return routePath === "/" ? base : `${base}${routePath}`;
|
||||||
: `${MODULE_ROUTE_PREFIX}/${moduleId}${routePath}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizedModuleHandlerFailure(c: Context): Response {
|
function sanitizedModuleHandlerFailure(c: Context): Response {
|
||||||
@@ -156,9 +201,10 @@ function sanitizeModuleResponse(response: Response): Response {
|
|||||||
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
|
* 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.
|
* 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
|
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
|
||||||
* frozen context carrying exactly `moduleId`, `principal`, `params`, and `services` — never a Hono
|
* frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`,
|
||||||
* `Context`, authorization store, or configuration; `services.database` is the module's only
|
* `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or
|
||||||
* reachable database surface, and is unreachable to anything short of a successfully authorized
|
* 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
|
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
|
||||||
* this handler at all).
|
* this handler at all).
|
||||||
*
|
*
|
||||||
@@ -189,15 +235,21 @@ export function mountModuleHttpRoutes(
|
|||||||
|
|
||||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||||
// all) is enforced before permission evaluation ever runs, so an oversized request never
|
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
|
||||||
// reaches authorization work, let alone the handler or the module database.
|
// never reaches authorization work, let alone the handler or the module database. In
|
||||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
|
// `streamed` mode no body byte is read until the handler reads it, and the handler runs only
|
||||||
|
// after authorization succeeds, so an unauthorized caller can never push an upload through
|
||||||
|
// (ADR 0042).
|
||||||
|
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||||
const path = moduleRoutePath(route.moduleId, route.path);
|
const path = moduleRoutePath(route.moduleId, route.path);
|
||||||
const finalHandler = async (c: Context): Promise<Response> => {
|
const finalHandler = async (c: Context): Promise<Response> => {
|
||||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||||
|
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
||||||
moduleId: route.moduleId,
|
moduleId: route.moduleId,
|
||||||
|
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
|
||||||
params: Object.freeze({ ...c.req.param() }),
|
params: Object.freeze({ ...c.req.param() }),
|
||||||
principal: getAuthorizedPrincipal(c),
|
principal: getAuthorizedPrincipal(c),
|
||||||
|
realtime: options.realtime.publisherFor(route.moduleId),
|
||||||
services: options.services,
|
services: options.services,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+300
-67
@@ -1,28 +1,55 @@
|
|||||||
import type { Socket } from "node:net";
|
import type { Socket } from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
import {
|
||||||
|
EMPTY_MODULE_SECRET_SOURCE,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
type ModuleSecretSource,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
createAuthorizationEvaluator,
|
createAuthorizationEvaluator,
|
||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
|
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||||
|
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||||
import {
|
import {
|
||||||
|
createIdentityNotifications,
|
||||||
|
createIdentityRuntime,
|
||||||
|
type IdentityNotificationReporter,
|
||||||
|
type IdentityRateLimitOverride,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||||
|
import {
|
||||||
|
createModuleConfigurationStore,
|
||||||
|
createModuleLifecycleStore,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
|
type ModuleConfigurationSettings,
|
||||||
|
type ModuleEligibilityResolution,
|
||||||
|
resolveModuleEligibility,
|
||||||
type StartedModuleRuntime,
|
type StartedModuleRuntime,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { serve, type ServerType } from "@hono/node-server";
|
import {
|
||||||
|
createModuleStorageLookup,
|
||||||
|
createS3StorageProvider,
|
||||||
|
type StorageProvider,
|
||||||
|
} from "@ekklesia/core/storage";
|
||||||
|
import { type ServerType, serve } from "@hono/node-server";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { createApiApp } from "./app.js";
|
import { createApiApp } from "./app.js";
|
||||||
import {
|
import {
|
||||||
type ClientAddressConfigInput,
|
|
||||||
type ClientAddressResolver,
|
type ClientAddressResolver,
|
||||||
createClientAddressResolver,
|
createClientAddressResolver,
|
||||||
wrapFetchWithClientAddress,
|
wrapFetchWithClientAddress,
|
||||||
} from "./client-address.js";
|
} from "./client-address.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
|
||||||
|
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||||
|
import { withDevicePrincipals } from "./device-principal.js";
|
||||||
|
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
|
||||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||||
|
|
||||||
const connectionTimeoutMs = 10_000;
|
const connectionTimeoutMs = 10_000;
|
||||||
@@ -118,6 +145,15 @@ export interface StartApiOptions {
|
|||||||
/** Test-only escape hatch: never set by the executable entry point. See
|
/** Test-only escape hatch: never set by the executable entry point. See
|
||||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
|
/**
|
||||||
|
* Where a verification or reset message that could not be delivered is reported.
|
||||||
|
*
|
||||||
|
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
|
||||||
|
* seam because the *only* externally observable difference between "the relay is down" and "there
|
||||||
|
* is no such account" must be this report — every HTTP response is deliberately identical — so a
|
||||||
|
* test needs somewhere to observe the operational failure the user is never shown.
|
||||||
|
*/
|
||||||
|
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
||||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
||||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||||
readonly modulesDirectoryOverride?: string;
|
readonly modulesDirectoryOverride?: string;
|
||||||
@@ -137,6 +173,17 @@ export interface StartApiOptions {
|
|||||||
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
||||||
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
||||||
readonly moduleHookTimeoutMsOverride?: number;
|
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> {
|
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||||
@@ -148,6 +195,31 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the mail provider's transport. */
|
||||||
|
async function safeCloseMail(provider: MailProvider | undefined): Promise<void> {
|
||||||
|
if (!provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await provider.close();
|
||||||
|
} catch {
|
||||||
|
// See safeCloseDatabase: never mask a failure that is already propagating.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own
|
||||||
|
* connection pool. */
|
||||||
|
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
|
||||||
|
if (!provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await provider.close();
|
||||||
|
} catch {
|
||||||
|
// See safeCloseDatabase: never mask a failure that is already propagating.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup
|
/** 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,
|
* 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`). */
|
* where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */
|
||||||
@@ -319,12 +391,15 @@ function bindServer(
|
|||||||
* facade each get a typed Drizzle view over it — see `./database.js`).
|
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||||
* then the Core identity runtime, authorization store, and evaluator.
|
* then the Core identity runtime, authorization store, and evaluator.
|
||||||
* 4. Start the module runtime from that exact same discovered inventory
|
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
|
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||||
* same services object every module's `start`/`stop` hook receives.
|
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||||
* 5. Create the Hono application, mounting the started module runtime's published routes and
|
* rediscovering modules a second time — passing it the same services object every module's
|
||||||
* passing the exact same services object to every module HTTP handler.
|
* `start`/`stop` hook receives.
|
||||||
* 6. Bind the Node HTTP server.
|
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
||||||
|
* UI for that same eligible set, and passing the exact same services object to every module HTTP
|
||||||
|
* handler.
|
||||||
|
* 7. Bind the Node HTTP server.
|
||||||
*
|
*
|
||||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
* 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
|
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
|
||||||
@@ -334,10 +409,20 @@ function bindServer(
|
|||||||
* module database facade, identity, authorization) exists before any executable module lifecycle
|
* 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
|
* 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
|
* registered and started successfully, so no request can ever reach a module before every module
|
||||||
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
|
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
|
||||||
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
|
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
|
||||||
* applies migrations, generates schema, or issues a module database query merely because modules
|
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
|
||||||
* were discovered — the database must already be migrated through `pnpm migrations:apply`.
|
* 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(
|
export async function startApi(
|
||||||
config: ApiRuntimeConfig,
|
config: ApiRuntimeConfig,
|
||||||
@@ -361,42 +446,148 @@ export async function startApi(
|
|||||||
transport: config.databaseTransport,
|
transport: config.databaseTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Both host providers are constructed from validated configuration **before** anything that uses
|
||||||
|
// them, and neither touches the network while being built: an S3 client opens no connection and a
|
||||||
|
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
|
||||||
|
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
|
||||||
|
// `undefined` for both, which is a complete, valid deployment.
|
||||||
|
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
||||||
|
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
||||||
|
|
||||||
let services: ModuleRuntimeServices;
|
let services: ModuleRuntimeServices;
|
||||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||||
|
let deviceStore: ReturnType<typeof createDeviceStore>;
|
||||||
try {
|
try {
|
||||||
services = Object.freeze({ database: databaseHandle.module });
|
services = Object.freeze({ database: databaseHandle.module });
|
||||||
runtime = createIdentityRuntime({
|
runtime = createIdentityRuntime({
|
||||||
config: config.identity,
|
config: config.identity,
|
||||||
db: databaseHandle.identity,
|
db: databaseHandle.identity,
|
||||||
|
// Identity's two outbound messages, composed here — the one place that knows both the
|
||||||
|
// validated identity configuration and the host's mail provider. Core's identity code learns
|
||||||
|
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
|
||||||
|
notifications: createIdentityNotifications({
|
||||||
|
authBaseUrl: config.identity.baseUrl,
|
||||||
|
dashboardOrigin: config.identity.dashboardOrigin,
|
||||||
|
mail: mailProvider,
|
||||||
|
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
|
||||||
|
}),
|
||||||
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
||||||
});
|
});
|
||||||
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
||||||
|
deviceStore = createDeviceStore(databaseHandle.devices);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const evaluator = createAuthorizationEvaluator({
|
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,
|
grants: authorizationStore,
|
||||||
platformOwners: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// 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.
|
// 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;
|
let moduleRuntime: StartedModuleRuntime;
|
||||||
try {
|
try {
|
||||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, {
|
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||||
|
configurations: eligibility.configurations,
|
||||||
|
storages: moduleStorages,
|
||||||
...(options.moduleHookTimeoutMsOverride === undefined
|
...(options.moduleHookTimeoutMsOverride === undefined
|
||||||
? {}
|
? {}
|
||||||
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
|
||||||
|
// `503` while the instance still finishes in-flight requests.
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
let app: Hono;
|
let app: Hono;
|
||||||
try {
|
try {
|
||||||
app = createApiApp({
|
app = createApiApp({
|
||||||
@@ -406,12 +597,66 @@ export async function startApi(
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
moduleRoutes: moduleRuntime.routes,
|
moduleRoutes: moduleRuntime.routes,
|
||||||
|
moduleRealtime: moduleRuntime.realtime,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
runtime,
|
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
// 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) {
|
} catch (error) {
|
||||||
await safeStopModuleRuntime(moduleRuntime);
|
await safeStopModuleRuntime(moduleRuntime);
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -427,6 +672,8 @@ export async function startApi(
|
|||||||
server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts);
|
server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeStopModuleRuntime(moduleRuntime);
|
await safeStopModuleRuntime(moduleRuntime);
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -436,7 +683,9 @@ export async function startApi(
|
|||||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
// 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
|
// 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
|
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
||||||
// observes it complete (or fail) exactly once. Order matters, and is now additionally
|
// observes it complete (or fail) exactly once. Realtime subscriptions are closed *before* HTTP
|
||||||
|
// drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every
|
||||||
|
// clean shutdown open until the forced-drain deadline. Order matters, and is now additionally
|
||||||
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
|
// 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;
|
// 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
|
// then the module runtime stops — its stop hooks may still use `services.database`, which is
|
||||||
@@ -448,7 +697,12 @@ export async function startApi(
|
|||||||
let shutdownPromise: Promise<void> | undefined;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = (): Promise<void> => {
|
const shutdown = (): Promise<void> => {
|
||||||
if (!shutdownPromise) {
|
if (!shutdownPromise) {
|
||||||
|
shuttingDown = true;
|
||||||
shutdownPromise = (async () => {
|
shutdownPromise = (async () => {
|
||||||
|
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
||||||
|
// 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);
|
const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
|
||||||
let moduleStopError: unknown;
|
let moduleStopError: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -456,6 +710,10 @@ export async function startApi(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
moduleStopError = 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);
|
await safeCloseDatabase(databaseHandle);
|
||||||
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
|
// 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,
|
// module-stop failure can never leave the pool open. Both failures are reported together,
|
||||||
@@ -499,65 +757,40 @@ export function installShutdownSignalHandlers(
|
|||||||
process.on("SIGTERM", handleSignal);
|
process.on("SIGTERM", handleSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined {
|
/**
|
||||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
* Turns one deployment environment into this process's startup inputs.
|
||||||
if (mode === undefined) {
|
*
|
||||||
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/
|
* Every host infrastructure value comes from `loadHostConfiguration` — the same function
|
||||||
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally
|
* `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
|
||||||
// half-configuring proxy mode by setting only one of the three variables.
|
* it can never disagree about what this deployment is configured to use. Only the two values that
|
||||||
return undefined;
|
* describe *this listener* rather than the host's infrastructure are read here.
|
||||||
}
|
*/
|
||||||
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
|
export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
|
||||||
return {
|
|
||||||
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
|
||||||
mode,
|
|
||||||
trustedProxies: trustedProxiesValue
|
|
||||||
?.split(",")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readConfigFromEnvironment(): {
|
|
||||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||||
readonly environment: "development" | "production";
|
readonly environment: "development" | "production";
|
||||||
|
readonly moduleSecrets: ModuleSecretSource;
|
||||||
} {
|
} {
|
||||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
const host = loadHostConfiguration(processEnvironment);
|
||||||
const trustedOrigins = trustedOriginsValue
|
|
||||||
?.split(",")
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter((origin) => origin.length > 0);
|
|
||||||
const clientAddress = readClientAddressInputFromEnvironment();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
environment: host.environment,
|
||||||
input: {
|
input: {
|
||||||
...(clientAddress === undefined ? {} : { clientAddress }),
|
clientAddress: host.clientAddress,
|
||||||
databaseTransport: {
|
databaseTransport: host.databaseTransport,
|
||||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
databaseUrl: host.databaseUrl,
|
||||||
? {}
|
host: processEnvironment.EKKLESIA_API_HOST,
|
||||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
identity: host.identity,
|
||||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
mail: host.mail,
|
||||||
? {}
|
port: processEnvironment.EKKLESIA_API_PORT,
|
||||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
storage: host.storage,
|
||||||
},
|
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
|
||||||
host: process.env.EKKLESIA_API_HOST,
|
|
||||||
identity: {
|
|
||||||
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
|
||||||
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
|
||||||
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
|
||||||
trustedOrigins,
|
|
||||||
},
|
|
||||||
port: process.env.EKKLESIA_API_PORT,
|
|
||||||
},
|
},
|
||||||
|
moduleSecrets: host.moduleSecrets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function main(): Promise<void> {
|
export async function main(): Promise<void> {
|
||||||
const { environment, input } = readConfigFromEnvironment();
|
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
|
||||||
const config = createApiRuntimeConfig(input, { environment });
|
const config = createApiRuntimeConfig(input, { environment });
|
||||||
const running = await startApi(config);
|
const running = await startApi(config, { moduleSecrets });
|
||||||
|
|
||||||
const address = running.server.address();
|
const address = running.server.address();
|
||||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+511
-3
@@ -3,18 +3,39 @@ import {
|
|||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
type ModuleDatabase,
|
||||||
type ModuleRuntimeServices,
|
type ModuleRuntimeServices,
|
||||||
|
moduleIdSchema,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
parsePermissionId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationEvaluator,
|
type AuthorizationEvaluator,
|
||||||
type AuthorizationStore,
|
type AuthorizationStore,
|
||||||
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
CORE_PERMISSION_REGISTRY,
|
CORE_PERMISSION_REGISTRY,
|
||||||
|
createPermissionRegistry,
|
||||||
type PermissionRegistry,
|
type PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type ModuleUiPublicationRegistry,
|
||||||
|
StagedModuleRealtimeRegistry,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
import {
|
||||||
|
type CreateApiAppAuthorizationOptions,
|
||||||
|
type CreateApiAppOptions,
|
||||||
|
createApiApp as createApiAppWithModules,
|
||||||
|
} from "../src/app.js";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -43,6 +64,19 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
|||||||
return Object.freeze({ database });
|
return Object.freeze({ database });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No module publishes UI in these wiring tests, so the publication route is mounted with an
|
||||||
|
* empty registry built by Core's own factory — never a hand-written stub, so the route under test
|
||||||
|
* always sees the real policy object production hands it. */
|
||||||
|
function emptyModuleUiPublication(): ModuleUiPublicationRegistry {
|
||||||
|
return createModuleUiPublicationRegistry([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most application-wiring cases exercise no modules. Keep that fact explicit in one test helper
|
||||||
|
* while production composition still requires the realtime registry with no implicit fallback. */
|
||||||
|
function createApiApp(options: Omit<CreateApiAppOptions, "moduleRealtime">) {
|
||||||
|
return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY });
|
||||||
|
}
|
||||||
|
|
||||||
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
||||||
* specific decision or store behavior override the relevant method explicitly. The registry
|
* specific decision or store behavior override the relevant method explicitly. The registry
|
||||||
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
|
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
|
||||||
@@ -70,11 +104,35 @@ function fakeAuthorization(overrides: {
|
|||||||
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
|
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
||||||
|
return createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({}),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("Ekklesia API Hono application", () => {
|
describe("Ekklesia API Hono application", () => {
|
||||||
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -84,14 +142,233 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(await response.text()).toBe("auth-ok");
|
expect(await response.json()).toEqual({ ok: true });
|
||||||
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces only a successful email sign-in body without exposing Better Auth's token or user", async () => {
|
||||||
|
const nativeCookie =
|
||||||
|
"__Secure-better-auth.session_token=opaque-cookie-value; Path=/; HttpOnly; Secure; SameSite=Lax";
|
||||||
|
const nativeBody = JSON.stringify({
|
||||||
|
token: "javascript-visible-session-token",
|
||||||
|
user: { email: "[email protected]", id: "user_123", name: "Owner" },
|
||||||
|
});
|
||||||
|
const nativeResponse = new Response(nativeBody, {
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": trustedOrigin,
|
||||||
|
"Content-Length": "4096",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": nativeCookie,
|
||||||
|
"X-Native-Auth-Header": "preserved",
|
||||||
|
},
|
||||||
|
status: 201,
|
||||||
|
statusText: "Signed In",
|
||||||
|
});
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(nativeResponse),
|
||||||
|
});
|
||||||
|
const app = createAuthWiringApp(runtime);
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/sign-in/email", {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const visibleBody = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.statusText).toBe("Signed In");
|
||||||
|
expect(JSON.parse(visibleBody)).toEqual({ ok: true });
|
||||||
|
expect(visibleBody).not.toContain("token");
|
||||||
|
expect(visibleBody).not.toContain("user");
|
||||||
|
expect(visibleBody).not.toContain("session");
|
||||||
|
expect(visibleBody).not.toContain("[email protected]");
|
||||||
|
expect(response.headers.get("set-cookie")).toBe(nativeCookie);
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("content-type")).toBe("application/json; charset=UTF-8");
|
||||||
|
expect(response.headers.get("content-length")).not.toBe("4096");
|
||||||
|
expect(response.headers.get("x-native-auth-header")).toBe("preserved");
|
||||||
|
expect(nativeResponse.bodyUsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"/api/auth/%73ign-in/email",
|
||||||
|
"/api/auth/sign-in%2Femail",
|
||||||
|
])("normalizes the router-equivalent encoded sign-in path %s", async (path) => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||||
|
Response.json({
|
||||||
|
token: "javascript-visible-session-token",
|
||||||
|
user: { email: "[email protected]" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const app = createAuthWiringApp(runtime);
|
||||||
|
|
||||||
|
const response = await app.request(path, { method: "POST" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ ok: true });
|
||||||
|
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a failed email sign-in response body and status unchanged", async () => {
|
||||||
|
const nativeFailure = JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" });
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||||
|
new Response(nativeFailure, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
status: 401,
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const app = createAuthWiringApp(runtime);
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.statusText).toBe("Unauthorized");
|
||||||
|
expect(await response.text()).toBe(nativeFailure);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a GET to the sign-in path", "GET", "/api/auth/sign-in/email"],
|
||||||
|
["a sign-out POST", "POST", "/api/auth/sign-out"],
|
||||||
|
["a trailing-slash sign-in POST", "POST", "/api/auth/sign-in/email/"],
|
||||||
|
])("leaves %s on Better Auth's native success contract", async (_label, method, path) => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(new Response("native-auth-body", { status: 200 })),
|
||||||
|
});
|
||||||
|
const app = createAuthWiringApp(runtime);
|
||||||
|
|
||||||
|
const response = await app.request(path, { method });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe("native-auth-body");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unused native session-token endpoints", () => {
|
||||||
|
const disabledRequests = [
|
||||||
|
["GET", "/api/auth/get-session"],
|
||||||
|
["POST", "/api/auth/get-session"],
|
||||||
|
["GET", "/api/auth/list-sessions"],
|
||||||
|
["POST", "/api/auth/update-session"],
|
||||||
|
["POST", "/api/auth/change-password"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
disabledRequests,
|
||||||
|
)("returns a fixed 404 for %s %s, including query and trailing-slash spellings, without invoking Better Auth", async (method, path) => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||||
|
Response.json({
|
||||||
|
session: { token: "javascript-visible-session-token" },
|
||||||
|
token: "another-session-token",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const app = createAuthWiringApp(runtime);
|
||||||
|
|
||||||
|
for (const requestPath of [path, `${path}?include=session`, `${path}/`]) {
|
||||||
|
const response = await app.request(requestPath, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(JSON.parse(body)).toEqual({ code: "NOT_FOUND" });
|
||||||
|
expect(body).not.toContain("token");
|
||||||
|
expect(body).not.toContain("session");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["GET", "/api/auth/%67et-session"],
|
||||||
|
["GET", "/api/auth/%6Cist-sessions"],
|
||||||
|
["POST", "/api/auth/%75pdate-session"],
|
||||||
|
["POST", "/api/auth/change-password%2F"],
|
||||||
|
["GET", "/api/auth/get-session%2F"],
|
||||||
|
["GET", "/api/auth%2Fget-session"],
|
||||||
|
["GET", "/api/auth/get-session%"],
|
||||||
|
])("does not delegate the encoded or malformed spelling %s %s", async (method, path) => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(Response.json({ session: { token: "exposed" } })),
|
||||||
|
});
|
||||||
|
const app = createAuthWiringApp(runtime);
|
||||||
|
|
||||||
|
const response = await app.request(path, { method });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies an oversized update-session body before the body limiter or Better Auth reads it", async () => {
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createAuthWiringApp(runtime);
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/update-session", {
|
||||||
|
body: "x",
|
||||||
|
headers: {
|
||||||
|
"content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["POST", "/api/auth/list-sessions"],
|
||||||
|
["GET", "/api/auth/update-session"],
|
||||||
|
["GET", "/api/auth/change-password"],
|
||||||
|
["GET", "/api/auth/list-sessions/export"],
|
||||||
|
])("leaves the neighbouring non-denied request %s %s on the native contract", async (method, path) => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(new Response("native-method-response", { status: 200 })),
|
||||||
|
});
|
||||||
|
const app = createAuthWiringApp(runtime);
|
||||||
|
|
||||||
|
const response = await app.request(path, { method });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe("native-method-response");
|
||||||
|
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -110,6 +387,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -130,6 +414,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -152,6 +443,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -174,6 +472,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -193,6 +498,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -214,6 +526,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -223,7 +542,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(await response.text()).toBe("auth-ok");
|
expect(await response.json()).toEqual({ ok: true });
|
||||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,6 +551,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -252,6 +578,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -279,6 +612,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -307,6 +647,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -340,6 +687,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -355,6 +709,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -372,6 +733,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -388,6 +756,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -402,6 +777,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -423,6 +805,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -447,6 +836,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -474,6 +870,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
};
|
};
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
@@ -492,6 +895,13 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -506,3 +916,101 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||||
|
const moduleId = moduleIdSchema.parse("announcements");
|
||||||
|
const permission = parsePermissionId("announcements.slideshow.view");
|
||||||
|
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||||
|
|
||||||
|
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
|
||||||
|
* it: the same composition a Dashboard on another origin reaches. */
|
||||||
|
function realtimeApp() {
|
||||||
|
const staged = new StagedModuleRealtimeRegistry();
|
||||||
|
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||||
|
registration.realtime.channel({ channel: "state", permission });
|
||||||
|
registration.commit();
|
||||||
|
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
|
||||||
|
const moduleRealtime = staged.publish();
|
||||||
|
const app = createApiAppWithModules({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
moduleRealtime,
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({
|
||||||
|
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
...CORE_PERMISSION_DECLARATIONS,
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: permission,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
runtime: fakeRuntime({
|
||||||
|
resolvePrincipal: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
createAuthenticatedPrincipal(
|
||||||
|
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, moduleRealtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
|
||||||
|
const { app, moduleRealtime } = realtimeApp();
|
||||||
|
|
||||||
|
const response = await app.request(endpoint, {
|
||||||
|
headers: { cookie: "session=1", origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||||
|
expect(response.headers.get("vary")).toContain("Origin");
|
||||||
|
await response.body?.cancel();
|
||||||
|
moduleRealtime.closeSubscriptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
|
||||||
|
const { app, moduleRealtime } = realtimeApp();
|
||||||
|
|
||||||
|
const response = await app.request(endpoint, {
|
||||||
|
headers: { cookie: "session=1", origin: untrustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
await response.body?.cancel();
|
||||||
|
moduleRealtime.closeSubscriptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
|
||||||
|
const app = createAuthWiringApp(fakeRuntime());
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal", {
|
||||||
|
headers: {
|
||||||
|
"access-control-request-headers": "content-type, x-module-custom",
|
||||||
|
"access-control-request-method": "PATCH",
|
||||||
|
origin: trustedOrigin,
|
||||||
|
},
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
|
||||||
|
"content-type",
|
||||||
|
);
|
||||||
|
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
|
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
|
||||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||||
|
|
||||||
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
|
|||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
identity: {
|
identity: {
|
||||||
baseUrl: "https://identity.example.test",
|
baseUrl: "https://identity.example.test",
|
||||||
|
// Defaults to the identity base URL: the Dashboard and the API share one origin unless a
|
||||||
|
// deployment separates them (ADR 0038).
|
||||||
|
dashboardOrigin: "https://identity.example.test",
|
||||||
publicSignupEnabled: false,
|
publicSignupEnabled: false,
|
||||||
secret: validSecret,
|
secret: validSecret,
|
||||||
trustedOrigins: ["https://identity.example.test"],
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
@@ -334,3 +337,24 @@ describe("API runtime configuration", () => {
|
|||||||
expect(publicText).not.toContain("db.internal.example");
|
expect(publicText).not.toContain("db.internal.example");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the API's trusted browser origins (ADR 0045)", () => {
|
||||||
|
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
|
||||||
|
const origins = apiTrustedOrigins({
|
||||||
|
baseUrl: "https://api.example.test",
|
||||||
|
trustedOrigins: ["https://dash.example.test"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
|
||||||
|
expect(Object.isFrozen(origins)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
|
||||||
|
expect(
|
||||||
|
apiTrustedOrigins({
|
||||||
|
baseUrl: "https://ekklesia.example.test",
|
||||||
|
trustedOrigins: ["https://ekklesia.example.test"],
|
||||||
|
}),
|
||||||
|
).toEqual(["https://ekklesia.example.test"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||||
|
dashboardHostAssetUrls: Object.freeze({
|
||||||
|
html: "file:///surface/browser/index.html",
|
||||||
|
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||||
|
DashboardHostAssetError,
|
||||||
|
loadDashboardHostAssets,
|
||||||
|
renderDashboardPage,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
const API_ORIGIN = "https://api.example.test";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard host asset loading", () => {
|
||||||
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
|
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
|
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||||
|
const script = "export const dashboardHost = true;\n";
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||||
|
|
||||||
|
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||||
|
|
||||||
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
|
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||||
|
]);
|
||||||
|
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||||
|
expect(assets).toEqual({
|
||||||
|
html: rendered,
|
||||||
|
htmlEtag: sha256Etag(rendered),
|
||||||
|
script,
|
||||||
|
scriptEtag: sha256Etag(script),
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||||
|
readFileMock.mockRejectedValueOnce(
|
||||||
|
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||||
|
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||||
|
expect(publicText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the script is missing even though the page was read", async () => {
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||||
|
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||||
|
|
||||||
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
|
DashboardHostAssetError,
|
||||||
|
);
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||||
|
for (const [html, script] of [
|
||||||
|
["", "export {};"],
|
||||||
|
["<html></html>", " \n"],
|
||||||
|
] as const) {
|
||||||
|
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
|
||||||
|
for (const html of [
|
||||||
|
"<!doctype html>",
|
||||||
|
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
|
||||||
|
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||||
|
]) {
|
||||||
|
readFileMock.mockReset();
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||||
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
|
DashboardHostAssetError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard page API origin (ADR 0045)", () => {
|
||||||
|
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
|
||||||
|
|
||||||
|
it("writes the API's origin into the one placeholder, in either topology", () => {
|
||||||
|
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
|
||||||
|
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
|
||||||
|
);
|
||||||
|
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
|
||||||
|
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://api.example.test/",
|
||||||
|
"https://api.example.test/api",
|
||||||
|
'https://api.example.test"><script>',
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"",
|
||||||
|
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
|
||||||
|
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
|
||||||
|
// while answering a request — the hallmark of a static directory — fails these tests.
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
DashboardHostConfigurationError,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
|
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("dashboard.page.read");
|
||||||
|
|
||||||
|
const PAGE_HTML =
|
||||||
|
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
|
||||||
|
const SCRIPT = "export const dashboard = 'built shell';\n";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||||
|
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
options: {
|
||||||
|
readonly assets?: DashboardHostAssets;
|
||||||
|
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||||
|
health: readyHealth(),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPageSecurityHeaders(response: Response): void {
|
||||||
|
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
||||||
|
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||||
|
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
|
||||||
|
expect(response.status, label).toBe(404);
|
||||||
|
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET / (the Dashboard page)", () => {
|
||||||
|
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
// The one CSP directive is the only one: the full policy is a separate decision.
|
||||||
|
expect(response.headers.get("content-security-policy")).not.toContain(";");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": ifNoneMatch },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status, ifNoneMatch).toBe(304);
|
||||||
|
expect(await response.text(), ifNoneMatch).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the full page again when the browser holds a different build's validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": sha256Etag("an older build") },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
|
||||||
|
const { app, evaluator, runtime } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
expect(response.headers.get("set-cookie")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||||
|
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(SCRIPT);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(304);
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept the page's validator as the script's", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": PAGE_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard host boundaries", () => {
|
||||||
|
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/index.html",
|
||||||
|
"/app.js",
|
||||||
|
"/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/",
|
||||||
|
"/assets/ekklesia-dashboard",
|
||||||
|
"/assets/ekklesia-dashboard/app.js/",
|
||||||
|
"/assets/ekklesia-dashboard/index.html",
|
||||||
|
"/assets/",
|
||||||
|
"/assets/ekklesia-dashboard/APP.JS",
|
||||||
|
"/dashboard",
|
||||||
|
"/login",
|
||||||
|
]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path), path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
|
||||||
|
"/%2e%2e/%2e%2e/etc/passwd",
|
||||||
|
"/../../etc/passwd",
|
||||||
|
"//assets/ekklesia-dashboard/app.js",
|
||||||
|
"/assets//ekklesia-dashboard/app.js",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%00.map",
|
||||||
|
]) {
|
||||||
|
const response = await app.request(path);
|
||||||
|
expect(await response.text(), path).not.toContain(SCRIPT);
|
||||||
|
expect(response.status, path).toBe(404);
|
||||||
|
}
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches the filesystem while answering, even across every served path", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
await app.request("/assets/ekklesia-dashboard/app.js.map");
|
||||||
|
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(await head.text()).toBe("");
|
||||||
|
|
||||||
|
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const { app } = buildApp({
|
||||||
|
moduleRoutes: [
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "dashboard",
|
||||||
|
path: "/",
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
|
||||||
|
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
|
||||||
|
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
|
||||||
|
// No display is published here, so the display host's script is absent rather than answered
|
||||||
|
// by the Dashboard's.
|
||||||
|
await expectSanitizedNotFound(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH),
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
);
|
||||||
|
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
|
||||||
|
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||||
|
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||||
|
|
||||||
|
for (const assets of [
|
||||||
|
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||||
|
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||||
|
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||||
|
]) {
|
||||||
|
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
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,
|
||||||
|
} 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: 7,
|
||||||
|
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 ModuleRuntimeServices,
|
||||||
|
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.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
},
|
||||||
|
id: "announcements",
|
||||||
|
manifestVersion: 7,
|
||||||
|
name: "Announcements fixture",
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: displayPermission,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default {
|
||||||
|
id: "announcements",
|
||||||
|
version: "1.0.0",
|
||||||
|
register({ realtime }) {
|
||||||
|
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
|
||||||
|
},
|
||||||
|
start({ realtime }) {
|
||||||
|
realtime.publish("state", { slides: [] });
|
||||||
|
},
|
||||||
|
stop() {},
|
||||||
|
};\n`,
|
||||||
|
),
|
||||||
|
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
|
||||||
|
]);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMigratedDatabase(label: string): Promise<string> {
|
||||||
|
databaseCounter += 1;
|
||||||
|
const name =
|
||||||
|
`ekk_device_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||||
|
const admin = await openAdministrativeClient();
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `create database "${name}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
const url = databaseUrl(name);
|
||||||
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModules,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModules, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPlatform(url: string, modulesDirectory: string): Promise<RunningApi> {
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
// The production posture: no public signup anywhere in this suite.
|
||||||
|
publicSignupEnabled: false,
|
||||||
|
secret: "integration-test-secret-0123456789abcdefgh",
|
||||||
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
return startApi(config, {
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
rateLimitOverride: { max: 1000, window: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiOrigin(running: RunningApi): string {
|
||||||
|
const address = running.server.address();
|
||||||
|
if (!address || typeof address !== "object") {
|
||||||
|
throw new IntegrationHarnessError("The API integration server has no bound address.");
|
||||||
|
}
|
||||||
|
return `http://127.0.0.1:${address.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-`
|
||||||
|
* whenever the configured base URL is https — which this harness's is. */
|
||||||
|
function cookiePair(response: Response, name: string): string | undefined {
|
||||||
|
for (const header of response.headers.getSetCookie()) {
|
||||||
|
const pair = header.split(";")[0]?.trim() ?? "";
|
||||||
|
const cookieName = pair.slice(0, Math.max(0, pair.indexOf("=")));
|
||||||
|
if (cookieName === name || cookieName.endsWith(name)) {
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookieFor(response: Response, name: string): string | undefined {
|
||||||
|
return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOwner(url: string, origin: string): Promise<string> {
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await createInitialPlatformOwner(
|
||||||
|
{
|
||||||
|
authorizationDb: authorization.db,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
identityDb: identity.db,
|
||||||
|
},
|
||||||
|
{ email, name: "Platform Owner", password: ownerPassword },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([authorization.close(), identity.close()]);
|
||||||
|
}
|
||||||
|
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
|
body: JSON.stringify({ email, password: ownerPassword }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const cookie = cookiePair(signIn, "better-auth.session_token");
|
||||||
|
if (signIn.status !== 200 || cookie === undefined) {
|
||||||
|
throw new IntegrationHarnessError("The platform owner could not sign in.");
|
||||||
|
}
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Platform {
|
||||||
|
readonly api: RunningApi;
|
||||||
|
readonly origin: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly ownerCookie: string;
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function platform(label: string): Promise<Platform> {
|
||||||
|
const url = await createMigratedDatabase(label);
|
||||||
|
const modulesDirectory = await createDisplayModuleDirectory();
|
||||||
|
const api = await startPlatform(url, modulesDirectory);
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
const ownerCookie = await createOwner(url, origin);
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
close: async () => {
|
||||||
|
await api.shutdown();
|
||||||
|
},
|
||||||
|
origin,
|
||||||
|
ownerCookie,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device's own browser: no human session, only whatever cookies the host has set on it. */
|
||||||
|
class DeviceBrowser {
|
||||||
|
#cookies = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(private readonly origin: string) {}
|
||||||
|
|
||||||
|
absorb(response: Response): void {
|
||||||
|
for (const header of response.headers.getSetCookie()) {
|
||||||
|
const pair = header.split(";")[0] ?? "";
|
||||||
|
const separator = pair.indexOf("=");
|
||||||
|
if (separator === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = pair.slice(0, separator).trim();
|
||||||
|
const value = pair.slice(separator + 1).trim();
|
||||||
|
if (value.length === 0 || /max-age=0/iu.test(header)) {
|
||||||
|
this.#cookies.delete(name);
|
||||||
|
} else {
|
||||||
|
this.#cookies.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header(): string {
|
||||||
|
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
has(name: string): boolean {
|
||||||
|
return this.#cookies.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
overwrite(name: string, value: string): void {
|
||||||
|
this.#cookies.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const cookie = this.header();
|
||||||
|
const response = await fetch(`${this.origin}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
...(cookie.length > 0 ? { cookie } : {}),
|
||||||
|
...(init.headers as Record<string, string> | undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.absorb(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
post(path: string, body: unknown): Promise<Response> {
|
||||||
|
return this.request(path, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminRequest(
|
||||||
|
origin: string,
|
||||||
|
cookie: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
overrides: RequestInit = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`${origin}${path}`, {
|
||||||
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
|
headers: {
|
||||||
|
cookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||||
|
...(overrides.headers as Record<string, string> | undefined),
|
||||||
|
},
|
||||||
|
method: body === undefined ? "GET" : "POST",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives a screen from "nobody has paired me" to "I hold a device session". */
|
||||||
|
async function pairDevice(
|
||||||
|
platform: Platform,
|
||||||
|
device: DeviceBrowser,
|
||||||
|
deviceName = "Foyer TV",
|
||||||
|
): Promise<{ readonly code: string }> {
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
if (requested.status !== 201) {
|
||||||
|
throw new IntegrationHarnessError("The pairing request was refused.");
|
||||||
|
}
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
const approved = await adminRequest(
|
||||||
|
platform.origin,
|
||||||
|
platform.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code, deviceName },
|
||||||
|
);
|
||||||
|
if (approved.status !== 200) {
|
||||||
|
throw new IntegrationHarnessError("The pairing approval was refused.");
|
||||||
|
}
|
||||||
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||||
|
if (((await claimed.json()) as { status: string }).status !== "paired") {
|
||||||
|
throw new IntegrationHarnessError("The device was not issued a session.");
|
||||||
|
}
|
||||||
|
return { code };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
|
if (admin) {
|
||||||
|
try {
|
||||||
|
for (const name of trackedDatabases) {
|
||||||
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const directory of trackedDirectories.splice(0)) {
|
||||||
|
await rm(directory, { force: true, recursive: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("display device pairing, end to end", () => {
|
||||||
|
it("takes an unpaired screen all the way to a working display, and back off again", async () => {
|
||||||
|
const running = await platform("e2e");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
// 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it
|
||||||
|
// needs in order to show a code is served.
|
||||||
|
expect((await device.request("/display/announcements")).status).toBe(200);
|
||||||
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
||||||
|
|
||||||
|
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
expect(requested.status).toBe(201);
|
||||||
|
const body = (await requested.json()) as Record<string, unknown>;
|
||||||
|
expect(body.code).toMatch(/^[0-9]{6}$/u);
|
||||||
|
// The claim secret is never in the body — only in a cookie script cannot read.
|
||||||
|
expect(JSON.stringify(body)).not.toContain("claim");
|
||||||
|
const claimCookie = setCookieFor(requested, "ekklesia_device_pairing");
|
||||||
|
expect(claimCookie).toMatch(/HttpOnly/iu);
|
||||||
|
|
||||||
|
// 3. Nothing has happened yet: polling says pending, and the display is still refused.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
).status,
|
||||||
|
).toBe("pending");
|
||||||
|
|
||||||
|
// 4. The administrator looks the code up, sees what it wants, and approves it by name.
|
||||||
|
const lookup = await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/lookups",
|
||||||
|
{ code: body.code },
|
||||||
|
);
|
||||||
|
expect(lookup.status).toBe(200);
|
||||||
|
expect(await lookup.json()).toMatchObject({
|
||||||
|
moduleId: "announcements",
|
||||||
|
permissionId: displayPermission,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code: body.code, deviceName: "Foyer TV" },
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
// 5. The screen collects its credential — through Set-Cookie, never through JSON.
|
||||||
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||||
|
expect(await claimed.json()).toEqual({ status: "paired" });
|
||||||
|
const sessionCookie = setCookieFor(claimed, "ekklesia_device_session");
|
||||||
|
expect(sessionCookie).toMatch(/HttpOnly/iu);
|
||||||
|
expect(sessionCookie).toMatch(/SameSite=Strict/iu);
|
||||||
|
expect(sessionCookie).toMatch(/Path=\//u);
|
||||||
|
expect(sessionCookie).not.toMatch(/Domain=/iu);
|
||||||
|
// The spent pairing cookie is cleared rather than left on the device.
|
||||||
|
expect(device.has("ekklesia_device_pairing")).toBe(false);
|
||||||
|
expect(device.has("ekklesia_device_session")).toBe(true);
|
||||||
|
|
||||||
|
// 6. The display now works, and reports the device as its viewer.
|
||||||
|
const descriptor = await device.request("/api/v1/module-displays/announcements");
|
||||||
|
expect(descriptor.status).toBe(200);
|
||||||
|
const descriptorBody = (await descriptor.json()) as {
|
||||||
|
viewer: { kind: string; deviceId?: string };
|
||||||
|
module: { permissions: string[] };
|
||||||
|
};
|
||||||
|
expect(descriptorBody.viewer.kind).toBe("device");
|
||||||
|
expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u);
|
||||||
|
// Exactly the one permission it was paired for.
|
||||||
|
expect(descriptorBody.module.permissions).toEqual([displayPermission]);
|
||||||
|
|
||||||
|
// 7. Realtime, through the ordinary permission-gated channel.
|
||||||
|
const stream = await device.request("/api/v1/module-realtime/announcements/state", {
|
||||||
|
headers: { accept: "text/event-stream" },
|
||||||
|
});
|
||||||
|
expect(stream.status).toBe(200);
|
||||||
|
expect(stream.headers.get("content-type")).toContain("text/event-stream");
|
||||||
|
await stream.body?.cancel();
|
||||||
|
|
||||||
|
// 8. The device is still nobody as far as the Dashboard is concerned.
|
||||||
|
expect(await (await device.request("/api/v1/principal")).json()).toEqual({
|
||||||
|
kind: "anonymous",
|
||||||
|
});
|
||||||
|
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
|
||||||
|
expect((await device.request("/api/v1/devices")).status).toBe(403);
|
||||||
|
|
||||||
|
// 9. The administrator revokes it.
|
||||||
|
const devices = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
|
||||||
|
expect(devices.devices).toHaveLength(1);
|
||||||
|
expect(devices.devices[0]?.name).toBe("Foyer TV");
|
||||||
|
expect(devices.devices[0]?.permissions).toEqual([displayPermission]);
|
||||||
|
const revoked = await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${devices.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(revoked.status).toBe(204);
|
||||||
|
|
||||||
|
// 10. Everything it could do, it can no longer do — while still holding its cookie.
|
||||||
|
expect(device.has("ekklesia_device_session")).toBe(true);
|
||||||
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await device.request("/api/v1/module-realtime/announcements/state", {
|
||||||
|
headers: { accept: "text/event-stream" },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
// And the screen can start over: the page it needs for a new code is still served.
|
||||||
|
expect((await device.request("/display/announcements")).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores no usable credential, and puts none in any response body", async () => {
|
||||||
|
const running = await platform("secrets");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
expect(credential.length).toBeGreaterThan(20);
|
||||||
|
|
||||||
|
const stored = await query<{ credential_hash: string; name: string }>(
|
||||||
|
running.url,
|
||||||
|
`select credential_hash, name from ekklesia_core_device`,
|
||||||
|
);
|
||||||
|
expect(stored).toHaveLength(1);
|
||||||
|
// The raw credential is nowhere in the database — only an irreversible digest of it.
|
||||||
|
expect(stored[0]?.credential_hash).not.toContain(credential);
|
||||||
|
expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u);
|
||||||
|
|
||||||
|
const everything = JSON.stringify(
|
||||||
|
await query(running.url, `select * from ekklesia_core_device_pairing_request`),
|
||||||
|
);
|
||||||
|
expect(everything).not.toContain(credential);
|
||||||
|
|
||||||
|
// The descriptor a module's UI can read carries no credential either.
|
||||||
|
const descriptor = await device.request("/api/v1/module-displays/announcements");
|
||||||
|
expect(await descriptor.text()).not.toContain(credential);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pairing cannot be talked into anything", () => {
|
||||||
|
it("derives the permission from the display, never from the request", async () => {
|
||||||
|
const running = await platform("derive");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
// The browser tries to name its own permission and its own module.
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
permission: "core.devices.manage",
|
||||||
|
permissionId: "core.devices.manage",
|
||||||
|
});
|
||||||
|
expect(requested.status).toBe(201);
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
|
||||||
|
const lookup = await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/lookups",
|
||||||
|
{ code },
|
||||||
|
);
|
||||||
|
// The host recorded the display's own declared permission and ignored the rest.
|
||||||
|
expect(await lookup.json()).toMatchObject({ permissionId: displayPermission });
|
||||||
|
|
||||||
|
const stored = await query<{ permission_id: string }>(
|
||||||
|
running.url,
|
||||||
|
`select permission_id from ekklesia_core_device_pairing_request`,
|
||||||
|
);
|
||||||
|
expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to start pairing for a module that has no display", async () => {
|
||||||
|
const running = await platform("nodisplay");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
for (const moduleId of ["calendar", "core", "../announcements", ""]) {
|
||||||
|
const response = await device.post("/api/v1/device-pairing/requests", { moduleId });
|
||||||
|
expect(response.status, moduleId).toBeGreaterThanOrEqual(400);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
await query(running.url, `select id from ekklesia_core_device_pairing_request`),
|
||||||
|
).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues nothing to someone who knows only the code", async () => {
|
||||||
|
const running = await platform("codeonly");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
const onlooker = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{
|
||||||
|
code,
|
||||||
|
deviceName: "Foyer TV",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Someone who read the code off the television — and even guessed the request exists —
|
||||||
|
// holds no claim cookie, so there is nothing for them to collect.
|
||||||
|
const stolen = await onlooker.post("/api/v1/device-pairing/claims", {});
|
||||||
|
expect(stolen.status).toBe(400);
|
||||||
|
expect(onlooker.has("ekklesia_device_session")).toBe(false);
|
||||||
|
|
||||||
|
// The real screen still gets its credential.
|
||||||
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
||||||
|
expect(await claimed.json()).toEqual({ status: "paired" });
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approval is an administrator's act", () => {
|
||||||
|
it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => {
|
||||||
|
const running = await platform("approve");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
|
||||||
|
// Anonymous.
|
||||||
|
const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, {
|
||||||
|
body: JSON.stringify({ code, deviceName: "Intruder TV" }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(anonymous.status).toBe(403);
|
||||||
|
|
||||||
|
// The paired-device cookie is not an administrator either — a device can never approve
|
||||||
|
// another device, or itself.
|
||||||
|
const selfApproval = await device.post("/api/v1/device-pairing/approvals", {
|
||||||
|
code,
|
||||||
|
deviceName: "Self TV",
|
||||||
|
});
|
||||||
|
expect(selfApproval.status).toBe(403);
|
||||||
|
|
||||||
|
// A real administrator from an untrusted origin: CSRF policy applies before anything else.
|
||||||
|
const crossOrigin = await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code, deviceName: "Foyer TV" },
|
||||||
|
{ headers: { origin: untrustedOrigin } },
|
||||||
|
);
|
||||||
|
expect(crossOrigin.status).toBe(403);
|
||||||
|
|
||||||
|
// Nothing was approved by any of that.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
).status,
|
||||||
|
).toBe("pending");
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot approve the same code twice, and cannot mint two credentials from one", async () => {
|
||||||
|
const running = await platform("single");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
||||||
|
moduleId: "announcements",
|
||||||
|
});
|
||||||
|
const { code } = (await requested.json()) as { code: string };
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code, deviceName: "Foyer TV" },
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
// A second approval of the same code finds nothing pending.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
"/api/v1/device-pairing/approvals",
|
||||||
|
{ code, deviceName: "Second TV" },
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
|
||||||
|
// Two claims race for one approved request; exactly one credential may exist.
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
device.post("/api/v1/device-pairing/claims", {}),
|
||||||
|
device.post("/api/v1/device-pairing/claims", {}),
|
||||||
|
]);
|
||||||
|
const statuses = [
|
||||||
|
((await first.json()) as { status: string }).status,
|
||||||
|
((await second.json()) as { status: string }).status,
|
||||||
|
];
|
||||||
|
expect(statuses.filter((status) => status === "paired")).toHaveLength(1);
|
||||||
|
expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => {
|
||||||
|
const running = await platform("repair");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device, "Foyer TV");
|
||||||
|
const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
const listed = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string }> };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
await pairDevice(running, device, "Foyer TV (replaced)");
|
||||||
|
const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
expect(secondCredential).not.toBe(firstCredential);
|
||||||
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
|
||||||
|
|
||||||
|
// Re-pairing creates a new identity rather than reviving the revoked one, so the old
|
||||||
|
// credential can never come back — presenting it again is simply anonymous.
|
||||||
|
const revokedBrowser = new DeviceBrowser(running.origin);
|
||||||
|
revokedBrowser.overwrite("ekklesia_device_session", firstCredential);
|
||||||
|
expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
const devices = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ revoked: boolean }> };
|
||||||
|
expect(devices.devices).toHaveLength(2);
|
||||||
|
expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||||
|
const running = await platform("mixed");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const deviceCredential = device.header();
|
||||||
|
|
||||||
|
// A browser presenting both: the person still wins, and stays a person.
|
||||||
|
const both = await fetch(`${running.origin}/api/v1/principal`, {
|
||||||
|
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
|
||||||
|
});
|
||||||
|
expect(((await both.json()) as { kind: string }).kind).toBe("authenticated");
|
||||||
|
|
||||||
|
// And that administrator can still open the display for testing, as a human.
|
||||||
|
const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, {
|
||||||
|
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
|
||||||
|
});
|
||||||
|
expect(descriptor.status).toBe(200);
|
||||||
|
expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe(
|
||||||
|
"authenticated",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
vi.mock("@ekklesia/shell/display-assets", () => ({
|
||||||
|
displayHostAssetUrls: Object.freeze({
|
||||||
|
html: "file:///surface/browser/display/index.html",
|
||||||
|
script: "file:///surface/browser/display/app.js",
|
||||||
|
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDisplayHostAssets,
|
||||||
|
DisplayHostAssetError,
|
||||||
|
loadDisplayHostAssets,
|
||||||
|
} from "../src/display-host-assets.js";
|
||||||
|
|
||||||
|
describe("display host asset loading", () => {
|
||||||
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
|
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
|
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||||
|
const script = "export const displayHost = true;\n";
|
||||||
|
const serviceWorker = "export const displayWorker = true;\n";
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(html)
|
||||||
|
.mockResolvedValueOnce(script)
|
||||||
|
.mockResolvedValueOnce(serviceWorker);
|
||||||
|
|
||||||
|
const assets = await loadDisplayHostAssets();
|
||||||
|
|
||||||
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
|
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||||
|
]);
|
||||||
|
expect(assets).toEqual({
|
||||||
|
html,
|
||||||
|
script,
|
||||||
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||||
|
readFileMock.mockRejectedValueOnce(
|
||||||
|
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await loadDisplayHostAssets().catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(failure).toBeInstanceOf(DisplayHostAssetError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The display host browser assets are unavailable.",
|
||||||
|
name: "DisplayHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DisplayHostAssetError: The display host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||||
|
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||||
|
expect(publicText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||||
|
for (const [html, script, serviceWorker] of [
|
||||||
|
["", "export {};", "export {};"],
|
||||||
|
["<html></html>", " \n", "export {};"],
|
||||||
|
["<html></html>", "export {};", " \n"],
|
||||||
|
] as const) {
|
||||||
|
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
|
||||||
|
DisplayHostAssetError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import { createHmac, randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
type IdentityNotificationFailure,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { SMTPServer } from "smtp-server";
|
||||||
|
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email verification and password recovery, end to end (ADR 0038).
|
||||||
|
*
|
||||||
|
* Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better
|
||||||
|
* Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The
|
||||||
|
* verification and reset links are **extracted from the delivered message** and then followed, which
|
||||||
|
* is the only way to prove the whole path rather than the pieces — a fake mail function would prove
|
||||||
|
* that a callback was invoked and nothing about whether the resulting link works.
|
||||||
|
*
|
||||||
|
* `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the
|
||||||
|
* internet and no real email is ever sent.
|
||||||
|
*
|
||||||
|
* Public signup is disabled in every scenario, matching production. Accounts that are not the
|
||||||
|
* bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses,
|
||||||
|
* which is deliberately not reachable over HTTP.
|
||||||
|
*/
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
const dashboardOrigin = "http://dashboard.localhost:5173";
|
||||||
|
const integrationSecret = "integration-test-secret-0123456789abcdefgh";
|
||||||
|
const initialPassword = "correct horse battery staple";
|
||||||
|
const replacementPassword = "an entirely different passphrase";
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const url = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof url !== "string" || url.trim() === "") {
|
||||||
|
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(name: string): string {
|
||||||
|
const url = new URL(baseDatabaseUrl());
|
||||||
|
url.pathname = `/${name}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<T extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<T>(text, [...values]);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
|
||||||
|
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModules,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModules, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freshInstallation(label: string): Promise<string> {
|
||||||
|
databaseCounter += 1;
|
||||||
|
const name =
|
||||||
|
`ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||||
|
const admin = await openAdministrativeClient();
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `create database "${name}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
const url = databaseUrl(name);
|
||||||
|
await applyCoreMigrations(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A delivered message, as the SMTP server actually received it. */
|
||||||
|
interface DeliveredMessage {
|
||||||
|
readonly to: readonly string[];
|
||||||
|
readonly data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailHarness {
|
||||||
|
readonly port: number;
|
||||||
|
readonly delivered: DeliveredMessage[];
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openResources: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const close of openResources.splice(0).reverse()) {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a free port before the API is configured.
|
||||||
|
*
|
||||||
|
* The identity base URL is what the emailed link is built from, so it has to be known *before*
|
||||||
|
* `startApi` binds. Without this the test could only assert the link's shape and would have to
|
||||||
|
* rewrite its host before following it — which is exactly the part worth proving. Reserving and
|
||||||
|
* releasing a port leaves a theoretical race, but nothing else on the loopback interface is
|
||||||
|
* competing for ephemeral ports inside this suite.
|
||||||
|
*/
|
||||||
|
async function reservePort(): Promise<number> {
|
||||||
|
const { createServer } = await import("node:net");
|
||||||
|
const server = createServer();
|
||||||
|
const port = await new Promise<number>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMailServer(): Promise<MailHarness> {
|
||||||
|
const delivered: DeliveredMessage[] = [];
|
||||||
|
const server = new SMTPServer({
|
||||||
|
authOptional: true,
|
||||||
|
disabledCommands: ["STARTTLS"],
|
||||||
|
onData(stream, session, callback) {
|
||||||
|
let data = "";
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
data += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
delivered.push({
|
||||||
|
data,
|
||||||
|
to: session.envelope.rcptTo.map((recipient) => recipient.address),
|
||||||
|
});
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
const address = server.server.address() as AddressInfo;
|
||||||
|
const harness: MailHarness = {
|
||||||
|
close: async () =>
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
}),
|
||||||
|
delivered,
|
||||||
|
port: address.port,
|
||||||
|
};
|
||||||
|
openResources.push(harness.close);
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly url: string;
|
||||||
|
readonly api: RunningApi;
|
||||||
|
readonly origin: string;
|
||||||
|
readonly mail: MailHarness | undefined;
|
||||||
|
readonly notificationFailures: IdentityNotificationFailure[];
|
||||||
|
readonly createIdentity: (input: {
|
||||||
|
readonly email: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly name: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A migrated installation with the API running, optionally with a working SMTP relay.
|
||||||
|
*
|
||||||
|
* `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the
|
||||||
|
* development environment; production configuration refuses both, which `identity-config` and
|
||||||
|
* `mail-contract` assert separately.
|
||||||
|
*/
|
||||||
|
async function installation(
|
||||||
|
label: string,
|
||||||
|
options: { readonly mail?: boolean } = {},
|
||||||
|
): Promise<Installation> {
|
||||||
|
const url = await freshInstallation(label);
|
||||||
|
const mail = options.mail === false ? undefined : await startMailServer();
|
||||||
|
const notificationFailures: IdentityNotificationFailure[] = [];
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
openResources.push(async () => {
|
||||||
|
await identity.close().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiPort = await reservePort();
|
||||||
|
const identityBaseUrl = `http://127.0.0.1:${apiPort}`;
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
publicSignupEnabled: false,
|
||||||
|
secret: integrationSecret,
|
||||||
|
trustedOrigins: [dashboardOrigin],
|
||||||
|
},
|
||||||
|
...(mail
|
||||||
|
? {
|
||||||
|
mail: {
|
||||||
|
fromAddress: "[email protected]",
|
||||||
|
fromName: "Ekklesia",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: String(mail.port),
|
||||||
|
security: "plaintext",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
port: apiPort,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
const api = await startApi(config, {
|
||||||
|
rateLimitOverride: { max: 1000, window: 1 },
|
||||||
|
reportNotificationFailure: (failure) => notificationFailures.push(failure),
|
||||||
|
});
|
||||||
|
openResources.push(async () => {
|
||||||
|
await api.shutdown().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = api.server.address();
|
||||||
|
if (!address || typeof address !== "object" || address.port !== apiPort) {
|
||||||
|
throw new IntegrationHarnessError("The API integration server did not bind the reserved port.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
mail,
|
||||||
|
notificationFailures,
|
||||||
|
origin: identityBaseUrl,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(
|
||||||
|
origin: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
headers: Readonly<Record<string, string>> = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
return await fetch(`${origin}${path}`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||||
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCookiePair(response: Response): string | undefined {
|
||||||
|
const setCookie = response.headers.get("set-cookie");
|
||||||
|
return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(
|
||||||
|
origin: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> {
|
||||||
|
const response = await post(origin, "/api/auth/sign-in/email", { email, password });
|
||||||
|
return { cookiePair: firstCookiePair(response), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function principalKind(origin: string, cookiePair: string | undefined): Promise<string> {
|
||||||
|
const response = await fetch(`${origin}/api/v1/principal`, {
|
||||||
|
headers: cookiePair ? { cookie: cookiePair } : {},
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as { readonly kind?: unknown };
|
||||||
|
return String(body.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a test reads a token, and only ever out of a message this suite's own SMTP server
|
||||||
|
* received. Nothing in the platform exposes one.
|
||||||
|
*
|
||||||
|
* Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by
|
||||||
|
* the encoder, and reassembling it here is what makes the extracted link the one a mail client would
|
||||||
|
* actually resolve.
|
||||||
|
*/
|
||||||
|
function extractLink(message: DeliveredMessage, contains: string): URL {
|
||||||
|
const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", "");
|
||||||
|
const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) =>
|
||||||
|
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||||
|
);
|
||||||
|
const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains));
|
||||||
|
if (!match) {
|
||||||
|
throw new IntegrationHarnessError(`No ${contains} link was delivered.`);
|
||||||
|
}
|
||||||
|
return new URL(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMessage(mail: MailHarness): DeliveredMessage {
|
||||||
|
const message = mail.delivered.at(-1);
|
||||||
|
if (!message) {
|
||||||
|
throw new IntegrationHarnessError("No message was delivered.");
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||||
|
client,
|
||||||
|
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||||
|
[email.toLowerCase()],
|
||||||
|
);
|
||||||
|
return rows[0]?.email_verified;
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account that has completed the real verification round trip.
|
||||||
|
*
|
||||||
|
* Recovery is only meaningful for a verified account, and building one through the actual flow —
|
||||||
|
* rather than by writing `email_verified` directly — keeps every recovery test standing on a
|
||||||
|
* verification path that genuinely works.
|
||||||
|
*/
|
||||||
|
async function verifiedAccountFor(install: Installation, email: string): Promise<void> {
|
||||||
|
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||||
|
await signIn(install.origin, email, initialPassword);
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||||
|
await fetch(link, { redirect: "manual" });
|
||||||
|
(install.mail as MailHarness).delivered.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
|
if (!admin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const name of trackedDatabases) {
|
||||||
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forges a verification token that is structurally valid but already expired.
|
||||||
|
*
|
||||||
|
* Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned
|
||||||
|
* release) — not a database row — so an expired one can be produced here from the same secret this
|
||||||
|
* suite configured, with no library internals and no waiting an hour. This is the only place any
|
||||||
|
* test constructs a token rather than reading one out of a delivered message.
|
||||||
|
*/
|
||||||
|
function forgeExpiredVerificationToken(email: string): string {
|
||||||
|
const encode = (value: object): string =>
|
||||||
|
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||||
|
const issued = Math.floor(Date.now() / 1000) - 7200;
|
||||||
|
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
exp: issued + 3600,
|
||||||
|
iat: issued,
|
||||||
|
})}`;
|
||||||
|
const signature = createHmac("sha256", integrationSecret)
|
||||||
|
.update(signingInput)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${signingInput}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("email verification, end to end", () => {
|
||||||
|
it("takes a new human account from unverified to signed in through a real emailed link", async () => {
|
||||||
|
const install = await installation("flow");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||||
|
|
||||||
|
// 1. Unverified. A correct password does not produce a session — the identity system refuses
|
||||||
|
// before creating one, so there is no half-authenticated state anywhere (ADR 0038).
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||||
|
const refused = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(refused.response.status).toBe(403);
|
||||||
|
expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||||
|
expect(refused.cookiePair).toBeUndefined();
|
||||||
|
expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous");
|
||||||
|
|
||||||
|
// 2. That attempt delivered a verification message through the real SMTP provider.
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
expect(mail.delivered).toHaveLength(1);
|
||||||
|
expect(lastMessage(mail).to).toEqual([email]);
|
||||||
|
expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address");
|
||||||
|
|
||||||
|
// 3. Follow the link exactly as it was delivered. Nothing rewrites it.
|
||||||
|
const link = extractLink(lastMessage(mail), "/verify-email");
|
||||||
|
expect(link.origin).toBe(install.origin);
|
||||||
|
const verified = await fetch(link, { redirect: "manual" });
|
||||||
|
|
||||||
|
// 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the
|
||||||
|
// API origin, and never to anything a request supplied.
|
||||||
|
expect(verified.status).toBe(302);
|
||||||
|
const landing = new URL(verified.headers.get("location") ?? "");
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email");
|
||||||
|
expect(landing.searchParams.get("error")).toBeNull();
|
||||||
|
// No session was minted by following an emailed link.
|
||||||
|
expect(verified.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||||
|
|
||||||
|
// 5. The same credentials now work, and the principal is a fully authenticated human.
|
||||||
|
const accepted = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(accepted.response.status).toBe(200);
|
||||||
|
expect(accepted.cookiePair).toBeDefined();
|
||||||
|
expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed token and an expired token, each with its own reason", async () => {
|
||||||
|
const install = await installation("bad-token");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Expired", password: initialPassword });
|
||||||
|
const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`);
|
||||||
|
|
||||||
|
for (const [token, reason] of [
|
||||||
|
["not-a-jwt", "INVALID_TOKEN"],
|
||||||
|
[forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"],
|
||||||
|
] as const) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`,
|
||||||
|
{ redirect: "manual" },
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const landing = new URL(response.headers.get("location") ?? "");
|
||||||
|
// The failure still lands on the host's own screen rather than a raw JSON error page.
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("error")).toBe(reason);
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when a link is followed twice, because the token is a signed claim", async () => {
|
||||||
|
const install = await installation("replay");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Replay", password: initialPassword });
|
||||||
|
await signIn(install.origin, email, initialPassword);
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||||
|
|
||||||
|
const first = await fetch(link, { redirect: "manual" });
|
||||||
|
const second = await fetch(link, { redirect: "manual" });
|
||||||
|
|
||||||
|
// Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed
|
||||||
|
// database row, so it is **not** single-use — it is valid until it expires. Replaying it is a
|
||||||
|
// no-op on an already-verified address and mints no session, which is what makes that safe.
|
||||||
|
// Mail scanners and prefetchers dereference links, so idempotence is a requirement here.
|
||||||
|
expect(first.status).toBe(302);
|
||||||
|
expect(second.status).toBe(302);
|
||||||
|
expect(second.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resends on request without revealing whether the address has an account", async () => {
|
||||||
|
const install = await installation("resend");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Resend", password: initialPassword });
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const known = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||||
|
const unknown = await post(install.origin, "/api/auth/send-verification-email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identical answers. The only difference anywhere is that a message was delivered, which the
|
||||||
|
// requester cannot observe.
|
||||||
|
expect(known.status).toBe(200);
|
||||||
|
expect(unknown.status).toBe(200);
|
||||||
|
expect(verifiedAlready.status).toBe(200);
|
||||||
|
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||||
|
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password recovery, end to end", () => {
|
||||||
|
it("replaces the password through a real emailed link and invalidates the old one", async () => {
|
||||||
|
const install = await installation("reset");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const requested = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
expect(requested.status).toBe(200);
|
||||||
|
expect(mail.delivered).toHaveLength(1);
|
||||||
|
expect(lastMessage(mail).to).toEqual([email]);
|
||||||
|
expect(lastMessage(mail).data).toContain("Reset your Ekklesia password");
|
||||||
|
|
||||||
|
// Follow the emailed link. Better Auth validates the token and hands the browser on to the
|
||||||
|
// Dashboard landing with it attached — which is where the host's reset form picks it up.
|
||||||
|
const link = extractLink(lastMessage(mail), "/reset-password/");
|
||||||
|
expect(link.origin).toBe(install.origin);
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
expect(handoff.status).toBe(302);
|
||||||
|
const landing = new URL(handoff.headers.get("location") ?? "");
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
const token = landing.searchParams.get("token") ?? "";
|
||||||
|
expect(token).not.toBe("");
|
||||||
|
|
||||||
|
const completed = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
expect(completed.status).toBe(200);
|
||||||
|
|
||||||
|
const oldCredential = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(oldCredential.response.status).toBe(401);
|
||||||
|
const newCredential = await signIn(install.origin, email, replacementPassword);
|
||||||
|
expect(newCredential.response.status).toBe(200);
|
||||||
|
expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes the reset token, so a captured link cannot be replayed", async () => {
|
||||||
|
const install = await installation("reset-replay");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const first = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
const second = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: "yet another different passphrase",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlike the verification token, this one is a database row Better Auth consumes.
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(second.status).toBe(400);
|
||||||
|
expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" });
|
||||||
|
expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs out every existing session for that account", async () => {
|
||||||
|
const install = await installation("reset-sessions");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const before = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated");
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of recovery is that it works when the credential is already compromised, so
|
||||||
|
// an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`).
|
||||||
|
expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the identity system's own password policy, with no reset-specific rule", async () => {
|
||||||
|
const install = await installation("reset-policy");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const tooShort = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: "short",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tooShort.status).toBe(400);
|
||||||
|
expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" });
|
||||||
|
// The old password still works: a rejected reset changes nothing.
|
||||||
|
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a request for an address with no account exactly as it answers a real one", async () => {
|
||||||
|
const install = await installation("enumeration");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const known = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const unknown = await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(known.status).toBe(unknown.status);
|
||||||
|
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||||
|
expect(JSON.stringify(await unknown.json())).not.toContain("stranger");
|
||||||
|
// Only the real account received anything.
|
||||||
|
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recovery when the installation has no outbound email", () => {
|
||||||
|
it("answers identically and records the operational failure out of band", async () => {
|
||||||
|
const install = await installation("no-mail", { mail: false });
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||||
|
|
||||||
|
const reset = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const resend = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||||
|
|
||||||
|
// The user-facing answer must not change: "we could not send it" for a real address and a
|
||||||
|
// generic success for an unknown one is an enumeration oracle that appears when SMTP breaks.
|
||||||
|
expect(reset.status).toBe(200);
|
||||||
|
expect(resend.status).toBe(200);
|
||||||
|
// The operator still learns about it, with a sanitized code and no address or token.
|
||||||
|
expect(install.notificationFailures).toEqual([
|
||||||
|
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(install.notificationFailures)).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still starts, serves the API, and refuses an unverified sign-in", async () => {
|
||||||
|
const install = await installation("no-mail-runs", { mail: false });
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||||
|
|
||||||
|
// Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification
|
||||||
|
// policy — which is exactly why the initial owner is created already verified.
|
||||||
|
expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200);
|
||||||
|
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recovery cannot be turned into a redirect or an account factory", () => {
|
||||||
|
it("refuses an attacker-supplied reset destination", async () => {
|
||||||
|
const install = await installation("redirect");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Redirect", password: initialPassword });
|
||||||
|
|
||||||
|
const external = await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email,
|
||||||
|
redirectTo: "https://evil.example.test/steal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external.status).toBe(403);
|
||||||
|
expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" });
|
||||||
|
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an attacker-supplied verification destination", async () => {
|
||||||
|
const install = await installation("redirect-verify");
|
||||||
|
|
||||||
|
for (const callbackURL of [
|
||||||
|
"https://evil.example.test",
|
||||||
|
"//evil.example.test",
|
||||||
|
"/\\evil.example.test",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
]) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||||
|
{ redirect: "manual" },
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.headers.get("location")).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => {
|
||||||
|
const install = await installation("redirect-ignored");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
|
||||||
|
// A *trusted* origin passes Better Auth's own check, so this is the case where the emailed
|
||||||
|
// link's destination would otherwise be caller-chosen. It is not: the message is built from
|
||||||
|
// configuration and the supplied value never reaches it.
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email,
|
||||||
|
redirectTo: `${dashboardOrigin}/somewhere-else`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const callback = new URL(link.searchParams.get("callbackURL") ?? "");
|
||||||
|
expect(callback.origin).toBe(dashboardOrigin);
|
||||||
|
expect(callback.pathname).toBe("/");
|
||||||
|
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
expect(link.href).not.toContain("somewhere-else");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps public signup disabled now that verification exists", async () => {
|
||||||
|
const install = await installation("signup");
|
||||||
|
|
||||||
|
const response = await post(install.origin, "/api/auth/sign-up/email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "Intruder",
|
||||||
|
password: initialPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email verification is not a licence to self-register. Future accounts arrive through
|
||||||
|
// deliberate administration, which this pass does not build.
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" });
|
||||||
|
expect(await emailVerifiedFlag(install.url, "[email protected]")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("managed devices are untouched by human email identity", () => {
|
||||||
|
it("leaves a paired device intact across a human password reset", async () => {
|
||||||
|
const install = await installation("devices");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
|
||||||
|
// A device is a row with a credential digest and no email, no password, and no verification
|
||||||
|
// state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove
|
||||||
|
// something this test is not about.
|
||||||
|
const client = new Client({ connectionString: install.url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
client,
|
||||||
|
'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)',
|
||||||
|
["Sanctuary display", "a".repeat(64)],
|
||||||
|
);
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>(
|
||||||
|
client,
|
||||||
|
'select "credential_hash", "revoked_at" from "ekklesia_core_device"',
|
||||||
|
);
|
||||||
|
expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]);
|
||||||
|
|
||||||
|
// The device table has no email or verification concept for a human flow to reach.
|
||||||
|
const columns = await safeQuery<{ column_name: string }>(
|
||||||
|
client,
|
||||||
|
"select column_name from information_schema.columns where table_name = 'ekklesia_core_device'",
|
||||||
|
);
|
||||||
|
const names = columns.map((column) => column.column_name).join(" ");
|
||||||
|
expect(names).not.toContain("email");
|
||||||
|
expect(names).not.toContain("verified");
|
||||||
|
expect(names).not.toContain("password");
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a device no mail, because a device has no address to send to", async () => {
|
||||||
|
const install = await installation("devices-mail");
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing about device identity participates in either flow.
|
||||||
|
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||||
|
expect(install.notificationFailures).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
type ApiHealthOptions,
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("health.status.read");
|
||||||
|
|
||||||
|
/** What a real driver failure can carry, and what must therefore never reach a response. */
|
||||||
|
const DRIVER_FAILURE_DETAIL =
|
||||||
|
"connect ECONNREFUSED postgresql://ekklesia:[email protected]:5432/ekklesia SELECT 1";
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthOptions(overrides: Partial<ApiHealthOptions> = {}) {
|
||||||
|
return {
|
||||||
|
checkDatabase: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||||
|
isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
health: ApiHealthOptions,
|
||||||
|
moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]),
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes,
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health,
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only
|
||||||
|
* timer functions these tests fake are `setTimeout` and `clearTimeout`. */
|
||||||
|
function drainMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */
|
||||||
|
async function settlesWithoutWaiting(promise: Response | Promise<Response>): Promise<boolean> {
|
||||||
|
let settled = false;
|
||||||
|
void Promise.resolve(promise).then(
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await drainMicrotasks();
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNotReady(response: Response): Promise<void> {
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ code: "NOT_READY" });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/health/live", () => {
|
||||||
|
it("returns 200 with a fixed body and Cache-Control: no-store", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_LIVE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("content-type")).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json()).toEqual({ status: "live" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||||
|
isModuleStartupComplete: vi.fn().mockReturnValue(false),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_LIVE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/health/ready", () => {
|
||||||
|
it("returns 200 with a fixed body when the database answers and module startup completed", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_READY_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ status: "ready" });
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_READY_PATH);
|
||||||
|
const text = await response.clone().text();
|
||||||
|
|
||||||
|
await expectNotReady(response);
|
||||||
|
for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) {
|
||||||
|
expect(text).not.toContain(fragment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a check that throws synchronously exactly like one that rejects", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(() => {
|
||||||
|
throw new Error(DRIVER_FAILURE_DETAIL);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 without touching the database before module startup completed", async () => {
|
||||||
|
const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) });
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 when shutdown began while the database query was in flight", async () => {
|
||||||
|
let startupComplete = true;
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(async () => {
|
||||||
|
startupComplete = false;
|
||||||
|
}),
|
||||||
|
isModuleStartupComplete: () => startupComplete,
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a fresh check for each sequential probe rather than caching a success", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await app.request(HEALTH_READY_PATH);
|
||||||
|
await app.request(HEALTH_READY_PATH);
|
||||||
|
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ready for a slow query that still answers inside the bound", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
let answer: () => void = () => undefined;
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const pending = app.request(HEALTH_READY_PATH);
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1);
|
||||||
|
answer();
|
||||||
|
|
||||||
|
expect((await pending).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readiness never piles up database checks", () => {
|
||||||
|
it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(() => new Promise<void>(() => undefined)),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
// Probes arriving inside the first check's window share it and its single deadline.
|
||||||
|
const first = app.request(HEALTH_READY_PATH);
|
||||||
|
const concurrent = app.request(HEALTH_READY_PATH);
|
||||||
|
await drainMicrotasks();
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||||
|
await expectNotReady(await first);
|
||||||
|
await expectNotReady(await concurrent);
|
||||||
|
|
||||||
|
// After the deadline the query is still unsettled: no new check starts, and each probe answers
|
||||||
|
// without waiting out another bound.
|
||||||
|
for (let probe = 0; probe < 20; probe += 1) {
|
||||||
|
const pending = app.request(HEALTH_READY_PATH);
|
||||||
|
expect(await settlesWithoutWaiting(pending)).toBe(true);
|
||||||
|
await expectNotReady(await pending);
|
||||||
|
}
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new check only once the timed-out query has actually settled", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
let settleHungQuery: (error: Error) => void = () => undefined;
|
||||||
|
const checkDatabase = vi
|
||||||
|
.fn<() => Promise<void>>()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((_resolve, reject) => {
|
||||||
|
settleHungQuery = reject;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const { app } = buildApp(healthOptions({ checkDatabase }));
|
||||||
|
|
||||||
|
const first = app.request(HEALTH_READY_PATH);
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||||
|
await expectNotReady(await first);
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
expect(checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The database cancels the abandoned statement; the next probe may finally ask again.
|
||||||
|
settleHungQuery(new Error(DRIVER_FAILURE_DETAIL));
|
||||||
|
await drainMicrotasks();
|
||||||
|
|
||||||
|
const recovered = await app.request(HEALTH_READY_PATH);
|
||||||
|
expect(recovered.status).toBe(200);
|
||||||
|
expect(checkDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health route boundaries", () => {
|
||||||
|
it.each([
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
const response = await app.request(path, { method });
|
||||||
|
expect(response.status, method).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
}
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => {
|
||||||
|
const { app, evaluator, runtime } = buildApp(healthOptions());
|
||||||
|
|
||||||
|
const response = await app.request(path, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot be shadowed by a module, even one whose id is "health"', async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const moduleRoutes = ["/live", "/ready"].map(
|
||||||
|
(path) =>
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "health",
|
||||||
|
path,
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
);
|
||||||
|
const { app } = buildApp(healthOptions(), moduleRoutes);
|
||||||
|
|
||||||
|
expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" });
|
||||||
|
expect(moduleHandler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The module's own routes still live where every module route lives, and only there.
|
||||||
|
const moduleResponse = await app.request("/api/v1/modules/health/ready");
|
||||||
|
expect(await moduleResponse.text()).toBe("module");
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { loadHostConfiguration } from "@ekklesia/core/host";
|
||||||
|
import { createMailProviderConfig } from "@ekklesia/core/mail";
|
||||||
|
import { createStorageProviderConfig } from "@ekklesia/core/storage";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { readConfigFromEnvironment } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
|
||||||
|
* previously could not: each executable read its own subset of the variables, and Doctor's subset
|
||||||
|
* omitted `S3_*` and the module secrets entirely.
|
||||||
|
*
|
||||||
|
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
|
||||||
|
* validates come from one `loadHostConfiguration` call and produce identical validated
|
||||||
|
* configuration, including every default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const secretKey = "sk-example-secret-material";
|
||||||
|
const smtpPassword = "sup3r-secret-relay-password";
|
||||||
|
|
||||||
|
function environment(
|
||||||
|
overrides: Readonly<Record<string, string | undefined>> = {},
|
||||||
|
): Readonly<Record<string, string | undefined>> {
|
||||||
|
return {
|
||||||
|
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
|
||||||
|
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
|
||||||
|
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
|
||||||
|
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
|
||||||
|
NODE_ENV: "production",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredInfrastructure = Object.freeze({
|
||||||
|
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||||
|
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||||
|
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||||
|
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
||||||
|
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
||||||
|
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
|
||||||
|
S3_ENDPOINT: "https://storage.example.org",
|
||||||
|
S3_SECRET_KEY: secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Exactly what Doctor validates, from exactly the same load the API performs. */
|
||||||
|
function doctorView(env: Readonly<Record<string, string | undefined>>) {
|
||||||
|
const host = loadHostConfiguration(env);
|
||||||
|
return {
|
||||||
|
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
|
||||||
|
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiView(env: Readonly<Record<string, string | undefined>>) {
|
||||||
|
const { environment: resolved, input } = readConfigFromEnvironment(env);
|
||||||
|
return createApiRuntimeConfig(input, { environment: resolved });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("host configuration parity between startApi and Doctor", () => {
|
||||||
|
it("derives identical object-storage configuration, defaults included", () => {
|
||||||
|
const env = environment(configuredInfrastructure);
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
||||||
|
// Named explicitly rather than only compared, so a change to either default fails here loudly.
|
||||||
|
expect(apiView(env).storage).toEqual({
|
||||||
|
accessKey: "AKIAEXAMPLEACCESSID",
|
||||||
|
bucketPrefix: "ekklesia",
|
||||||
|
deliveryEndpoint: "https://storage.example.org",
|
||||||
|
endpoint: "https://storage.example.org",
|
||||||
|
forcePathStyle: true,
|
||||||
|
region: "us-east-1",
|
||||||
|
secretKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives identical outbound-email configuration, defaults included", () => {
|
||||||
|
const env = environment(configuredInfrastructure);
|
||||||
|
|
||||||
|
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
||||||
|
expect(apiView(env).mail).toMatchObject({
|
||||||
|
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
|
||||||
|
// must fail in Doctor for the same reason it fails at runtime.
|
||||||
|
heloName: "mail.example.org",
|
||||||
|
host: "smtp.example.org",
|
||||||
|
port: 587,
|
||||||
|
security: "starttls",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees that an installation with neither service is complete", () => {
|
||||||
|
const env = environment();
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toBeUndefined();
|
||||||
|
expect(doctorView(env).storage).toBeUndefined();
|
||||||
|
expect(apiView(env).mail).toBeUndefined();
|
||||||
|
expect(doctorView(env).mail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries every storage field the direct-delivery pass established", () => {
|
||||||
|
const env = environment({
|
||||||
|
...configuredInfrastructure,
|
||||||
|
S3_BUCKET_PREFIX: "rogc",
|
||||||
|
S3_DELIVERY_ENDPOINT: "https://files.example.org",
|
||||||
|
S3_FORCE_PATH_STYLE: "false",
|
||||||
|
S3_REGION: "eu-west-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
||||||
|
expect(apiView(env).storage).toMatchObject({
|
||||||
|
bucketPrefix: "rogc",
|
||||||
|
deliveryEndpoint: "https://files.example.org",
|
||||||
|
forcePathStyle: false,
|
||||||
|
region: "eu-west-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the same malformed environment with the same sanitized code", () => {
|
||||||
|
for (const [overrides, code] of [
|
||||||
|
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
|
||||||
|
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
|
||||||
|
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
|
||||||
|
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
||||||
|
],
|
||||||
|
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
|
||||||
|
"MAIL_HELO_NAME_INVALID",
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
const env = environment(overrides);
|
||||||
|
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
||||||
|
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves module secrets from the same source the runtime is given", () => {
|
||||||
|
const env = environment({
|
||||||
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
|
||||||
|
).toBe("module-secret-material");
|
||||||
|
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
|
||||||
|
"module-secret-material",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads only this listener's own address and port outside the shared loader", () => {
|
||||||
|
const config = apiView(
|
||||||
|
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.host).toBe("0.0.0.0");
|
||||||
|
expect(config.port).toBe(8080);
|
||||||
|
// Defaults, when unset, stay where they always were.
|
||||||
|
const defaults = apiView(environment());
|
||||||
|
expect(defaults.host).toBe("127.0.0.1");
|
||||||
|
expect(defaults.port).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
|
||||||
|
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
|
||||||
|
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
|
||||||
|
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
|
||||||
|
|
||||||
|
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
|
||||||
|
expect(() => apiView(env)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import {
|
||||||
|
createAuthorizationDrizzleDatabase,
|
||||||
|
createInitialPlatformOwner,
|
||||||
|
InitialPlatformOwnerError,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial platform-owner bootstrap, against a real PostgreSQL and the real HTTP transport
|
||||||
|
* (ADR 0035).
|
||||||
|
*
|
||||||
|
* The point of this suite is that it does **not** stop at asserting rows. A fresh installation is
|
||||||
|
* bootstrapped and then the resulting account is signed in through the exact browser-facing login
|
||||||
|
* transport Phase 7B Pass 1 established, its principal is read back, and it is used to reach a
|
||||||
|
* platform-owner-protected resource. Anything less would prove the command wrote a user, not that
|
||||||
|
* an operator can actually get into their dashboard — which is the only thing this pass is for.
|
||||||
|
*
|
||||||
|
* Every scenario runs with `publicSignupEnabled: false`, the production posture, so nothing here
|
||||||
|
* can accidentally depend on the signup route the real deployment does not have.
|
||||||
|
*/
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
||||||
|
const bootstrapCliPath = path.join(
|
||||||
|
repositoryRoot,
|
||||||
|
"core",
|
||||||
|
"dist",
|
||||||
|
"cli",
|
||||||
|
"create-initial-owner.js",
|
||||||
|
);
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
const identityBaseUrl = "https://identity.example.test";
|
||||||
|
const dashboardOrigin = "https://dashboard.example.test";
|
||||||
|
const ownerPassword = "correct horse battery staple";
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const url = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof url !== "string" || url.trim() === "") {
|
||||||
|
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(name: string): string {
|
||||||
|
const url = new URL(baseDatabaseUrl());
|
||||||
|
url.pathname = `/${name}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDatabaseName(label: string): string {
|
||||||
|
databaseCounter += 1;
|
||||||
|
return `ekk_owner_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<T extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<T>(text, [...values]);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
|
||||||
|
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDisposableDatabase(label: string): Promise<string> {
|
||||||
|
const name = makeDatabaseName(label);
|
||||||
|
const admin = await openAdministrativeClient();
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `create database "${name}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
return databaseUrl(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-owner-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModules,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModules, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh, migrated installation with no identity and no owner — exactly what an operator has
|
||||||
|
* after `docker compose up` and `pnpm migrations:apply`. */
|
||||||
|
async function freshInstallation(label: string): Promise<string> {
|
||||||
|
const url = await createDisposableDatabase(label);
|
||||||
|
await applyCoreMigrations(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootstrapHandles {
|
||||||
|
readonly run: (
|
||||||
|
email: string,
|
||||||
|
password?: string,
|
||||||
|
name?: string,
|
||||||
|
) => ReturnType<typeof createInitialPlatformOwner>;
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives the same operation the CLI drives, with the same production composition. */
|
||||||
|
function bootstrapAgainst(url: string): BootstrapHandles {
|
||||||
|
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
authorization.close().catch(() => undefined),
|
||||||
|
identity.close().catch(() => undefined),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
run: (email, password = ownerPassword, name = "Platform Owner") =>
|
||||||
|
createInitialPlatformOwner(
|
||||||
|
{
|
||||||
|
authorizationDb: authorization.db,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
identityDb: identity.db,
|
||||||
|
},
|
||||||
|
{ email, name, password },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The production posture: public signup disabled, exactly as a real deployment must run — and, just
|
||||||
|
* as deliberately, **no outbound email configured at all**. A fresh installation has no relay yet,
|
||||||
|
* which is the whole reason the initial owner is created already verified (ADR 0038).
|
||||||
|
*/
|
||||||
|
function ownerApiConfig(url: string): ReturnType<typeof createApiRuntimeConfig> {
|
||||||
|
return createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
publicSignupEnabled: false,
|
||||||
|
secret: "integration-test-secret-0123456789abcdefgh",
|
||||||
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||||
|
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiOrigin(running: RunningApi): string {
|
||||||
|
const address = running.server.address();
|
||||||
|
if (!address || typeof address !== "object") {
|
||||||
|
throw new IntegrationHarnessError("The API integration server has no bound address.");
|
||||||
|
}
|
||||||
|
return `http://127.0.0.1:${address.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCookiePair(setCookieHeader: string): string {
|
||||||
|
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(
|
||||||
|
origin: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ cookiePair?: string; response: Response }> {
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const setCookie = response.headers.get("set-cookie");
|
||||||
|
return setCookie === null ? { response } : { cookiePair: firstCookiePair(setCookie), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
interface CliOutcome {
|
||||||
|
readonly code: number;
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the real compiled CLI as a real child process, with the password on stdin and never in
|
||||||
|
* `argv`. */
|
||||||
|
async function runBootstrapCli(
|
||||||
|
databaseUrl: string | undefined,
|
||||||
|
args: readonly string[],
|
||||||
|
password: string | undefined,
|
||||||
|
): Promise<CliOutcome> {
|
||||||
|
const environment: Record<string, string> = {
|
||||||
|
...(process.env as Record<string, string>),
|
||||||
|
NODE_ENV: "development",
|
||||||
|
};
|
||||||
|
if (databaseUrl === undefined) {
|
||||||
|
delete environment.DATABASE_URL;
|
||||||
|
} else {
|
||||||
|
environment.DATABASE_URL = databaseUrl;
|
||||||
|
}
|
||||||
|
const child = execFileAsync(process.execPath, [bootstrapCliPath, ...args], {
|
||||||
|
env: environment,
|
||||||
|
});
|
||||||
|
child.child.stdin?.end(password ?? "");
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await child;
|
||||||
|
return { code: 0, stderr, stdout };
|
||||||
|
} catch (error) {
|
||||||
|
const failure = error as { code?: number; stdout?: string; stderr?: string };
|
||||||
|
return {
|
||||||
|
code: typeof failure.code === "number" ? failure.code : 1,
|
||||||
|
stderr: failure.stderr ?? "",
|
||||||
|
stdout: failure.stdout ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countRows(url: string, table: string): Promise<number> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ count: string }>(
|
||||||
|
client,
|
||||||
|
`select count(*)::text from "${table}"`,
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.count ?? "0");
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Better Auth's own `emailVerified` column — the same value its `/verify-email` endpoint writes. */
|
||||||
|
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||||
|
client,
|
||||||
|
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
return rows[0]?.email_verified;
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
|
if (!admin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const name of trackedDatabases) {
|
||||||
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("initial platform-owner bootstrap", () => {
|
||||||
|
it("takes a fresh installation all the way to an authenticated owner in the browser", async () => {
|
||||||
|
const url = await freshInstallation("browserproof");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
let created: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
|
||||||
|
try {
|
||||||
|
created = await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
expect(created.identity).toBe("created");
|
||||||
|
expect(created.email).toBe(email.toLowerCase());
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
|
||||||
|
// 1. The exact browser-facing login transport, on a deployment where signup does not exist.
|
||||||
|
const session = await signIn(origin, email, ownerPassword);
|
||||||
|
expect(session.response.status).toBe(200);
|
||||||
|
expect(session.cookiePair).toBeDefined();
|
||||||
|
const cookie = session.cookiePair as string;
|
||||||
|
|
||||||
|
// 2. The principal the browser bootstrap reads.
|
||||||
|
const principalResponse = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
|
||||||
|
expect(principalResponse.status).toBe(200);
|
||||||
|
const principal = (await principalResponse.json()) as {
|
||||||
|
kind: string;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
expect(principal.kind).toBe("authenticated");
|
||||||
|
expect(principal.userId).toBe(created.userId);
|
||||||
|
|
||||||
|
// 3. A platform-owner-protected resource actually opens for them. This is the assertion that
|
||||||
|
// separates "a row was written" from "the operator can run their platform".
|
||||||
|
const grants = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`, {
|
||||||
|
headers: { cookie },
|
||||||
|
});
|
||||||
|
expect(grants.status).toBe(200);
|
||||||
|
|
||||||
|
// 4. And the same resource is still closed to everyone else.
|
||||||
|
const anonymous = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`);
|
||||||
|
expect(anonymous.status).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves public signup disabled — the owner exists without a signup route existing", async () => {
|
||||||
|
const url = await freshInstallation("nosignup");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
const attempted = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `intruder-${randomBytes(4).toString("hex")}@example.test`,
|
||||||
|
name: "Intruder",
|
||||||
|
password: ownerPassword,
|
||||||
|
}),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bootstrap command created an account; it did not open a door for anyone else to.
|
||||||
|
expect(attempted.status).toBe(400);
|
||||||
|
expect(await attempted.text()).toContain("EMAIL_PASSWORD_SIGN_UP_DISABLED");
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
|
||||||
|
// And the owner can still sign in, so disabling signup did not disable the account.
|
||||||
|
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the guard", () => {
|
||||||
|
it("refuses a second bootstrap, changes no password, and creates no second owner", async () => {
|
||||||
|
const url = await freshInstallation("guard");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(email);
|
||||||
|
|
||||||
|
// Same address, different password: must not become a password reset.
|
||||||
|
await expect(bootstrap.run(email, "an entirely different password")).rejects.toMatchObject({
|
||||||
|
code: "INITIAL_OWNER_ALREADY_COMPLETED",
|
||||||
|
});
|
||||||
|
// A different address must not become a second owner.
|
||||||
|
await expect(
|
||||||
|
bootstrap.run(`second-${randomBytes(4).toString("hex")}@example.test`),
|
||||||
|
).rejects.toMatchObject({ code: "INITIAL_OWNER_ALREADY_COMPLETED" });
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
// The refused second attempt with a new address created no stray identity: the sentinel is
|
||||||
|
// checked before anything is created, not after.
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await signIn(origin, email, "an entirely different password")).response.status,
|
||||||
|
).not.toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validation reuses the identity system's own policy", () => {
|
||||||
|
it("rejects an invalid email, and a password outside the 8–128 bound", async () => {
|
||||||
|
const url = await freshInstallation("validation");
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await expect(bootstrap.run("not-an-email-address")).rejects.toMatchObject({
|
||||||
|
code: "INITIAL_OWNER_EMAIL_INVALID",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
bootstrap.run(`short-${randomBytes(4).toString("hex")}@example.test`, "1234567"),
|
||||||
|
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_SHORT" });
|
||||||
|
await expect(
|
||||||
|
bootstrap.run(`long-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(129)),
|
||||||
|
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_LONG" });
|
||||||
|
|
||||||
|
// Exactly the boundary the browser accepts, so the two can never diverge.
|
||||||
|
await expect(
|
||||||
|
bootstrap.run(`edge-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(128)),
|
||||||
|
).resolves.toMatchObject({ identity: "created" });
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every rejection happened before anything was written, and the one success is the only row.
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never puts the password into an error", async () => {
|
||||||
|
const url = await freshInstallation("nosecrets");
|
||||||
|
const secret = "sup3rSecretBootstrapPassword!";
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await bootstrap.run("not-an-email-address", secret);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(InitialPlatformOwnerError);
|
||||||
|
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||||||
|
expect(rendered).not.toContain(secret);
|
||||||
|
expect(rendered).not.toContain("postgres");
|
||||||
|
expect(rendered).not.toContain("select");
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("an identity that already exists", () => {
|
||||||
|
it("adopts a usable account and leaves its password untouched", async () => {
|
||||||
|
const url = await freshInstallation("adopt");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
// Exactly the partial failure this guards: the identity committed, the grant never ran.
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await createBootstrapIdentityCreator(identity.db)({
|
||||||
|
email,
|
||||||
|
name: "Half Finished",
|
||||||
|
password: ownerPassword,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await identity.close();
|
||||||
|
}
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
const result = await bootstrap.run(email, "a different password than before");
|
||||||
|
expect(result.identity).toBe("adopted");
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
// Adoption granted ownership. It did not take the account over: the original password still
|
||||||
|
// works and the one supplied to the rerun does not.
|
||||||
|
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await signIn(origin, email, "a different password than before")).response.status,
|
||||||
|
).not.toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a credential-less row rather than consuming bootstrap on an unusable owner", async () => {
|
||||||
|
const url = await freshInstallation("unusable");
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
client,
|
||||||
|
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
values ($1, $2, $3, false, now(), now())`,
|
||||||
|
["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Shell Row", "[email protected]"],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await expect(bootstrap.run("[email protected]")).rejects.toMatchObject({
|
||||||
|
code: "INITIAL_OWNER_IDENTITY_UNUSABLE",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decisive part: bootstrap is still available. Granting ownership to a row nobody can
|
||||||
|
// authenticate as would have consumed the single use and left the installation unusable with
|
||||||
|
// no second chance.
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partial failure", () => {
|
||||||
|
it("reports a failed grant honestly and stays recoverable by rerunning", async () => {
|
||||||
|
const url = await freshInstallation("partial");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const authorization = createAuthorizationDrizzleDatabase(url);
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
createInitialPlatformOwner(
|
||||||
|
{
|
||||||
|
authorizationDb: authorization.db,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
grantOwnership: () => {
|
||||||
|
throw new Error("the grant failed after the identity committed");
|
||||||
|
},
|
||||||
|
identityDb: identity.db,
|
||||||
|
},
|
||||||
|
{ email, name: "Owner", password: ownerPassword },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" });
|
||||||
|
} finally {
|
||||||
|
await Promise.all([authorization.close(), identity.close()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The identity committed; the grant did not; the single-use sentinel was never consumed.
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
|
||||||
|
|
||||||
|
// Rerunning the identical command finishes the job, which is the whole recovery story.
|
||||||
|
const retry = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await expect(retry.run(email)).resolves.toMatchObject({ identity: "adopted" });
|
||||||
|
} finally {
|
||||||
|
await retry.close();
|
||||||
|
}
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("concurrency", () => {
|
||||||
|
it("gives exactly one owner when several bootstraps race", async () => {
|
||||||
|
const url = await freshInstallation("race");
|
||||||
|
const attempts = 5;
|
||||||
|
const handles = Array.from({ length: attempts }, () => bootstrapAgainst(url));
|
||||||
|
try {
|
||||||
|
const outcomes = await Promise.allSettled(
|
||||||
|
handles.map((handle, index) =>
|
||||||
|
handle.run(`racer-${index}-${randomBytes(3).toString("hex")}@example.test`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled");
|
||||||
|
expect(fulfilled).toHaveLength(1);
|
||||||
|
for (const outcome of outcomes.filter((candidate) => candidate.status === "rejected")) {
|
||||||
|
expect((outcome as PromiseRejectedResult).reason).toMatchObject({
|
||||||
|
code: "INITIAL_OWNER_ALREADY_COMPLETED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await Promise.all(handles.map((handle) => handle.close()));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(1);
|
||||||
|
// The losers created nothing at all. Serializing whole invocations — not just the grant — is
|
||||||
|
// what keeps a race from leaving signable-in accounts that own nothing behind.
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the command itself", () => {
|
||||||
|
it("succeeds, prints something useful, and leaks nothing", async () => {
|
||||||
|
const url = await freshInstallation("cli");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const result = await runBootstrapCli(url, ["--email", email], ownerPassword);
|
||||||
|
|
||||||
|
expect(result.code).toBe(0);
|
||||||
|
expect(result.stdout).toContain("Initial platform owner created successfully.");
|
||||||
|
expect(result.stdout).toContain(email.toLowerCase());
|
||||||
|
const everything = `${result.stdout} ${result.stderr}`;
|
||||||
|
expect(everything).not.toContain(ownerPassword);
|
||||||
|
expect(everything).not.toContain("postgresql://");
|
||||||
|
expect(everything).not.toContain("password");
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
// The command's real product: an operator who can now sign in.
|
||||||
|
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a password supplied on the command line", async () => {
|
||||||
|
const url = await freshInstallation("argv");
|
||||||
|
const result = await runBootstrapCli(
|
||||||
|
url,
|
||||||
|
["--email", "[email protected]", "--password", "hunter2hunter2"],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
expect(result.stderr).toContain("--password is not supported");
|
||||||
|
expect(await countRows(url, "ekklesia_core_user")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails clearly without a database, and refuses to migrate one itself", async () => {
|
||||||
|
const missing = await runBootstrapCli(
|
||||||
|
undefined,
|
||||||
|
["--email", "[email protected]"],
|
||||||
|
"whatever",
|
||||||
|
);
|
||||||
|
expect(missing.code).toBe(1);
|
||||||
|
expect(missing.stderr).toContain("DATABASE_URL is required.");
|
||||||
|
|
||||||
|
// A database that exists but was never migrated is a deployment step that has not happened,
|
||||||
|
// and this command says so rather than quietly running migrations for a production operator.
|
||||||
|
const unmigrated = await createDisposableDatabase("unmigrated");
|
||||||
|
const result = await runBootstrapCli(
|
||||||
|
unmigrated,
|
||||||
|
["--email", "[email protected]"],
|
||||||
|
ownerPassword,
|
||||||
|
);
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
expect(result.stderr).toContain("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: unmigrated });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const tables = await safeQuery<{ count: string }>(
|
||||||
|
client,
|
||||||
|
`select count(*)::text from information_schema.tables where table_schema = 'public'`,
|
||||||
|
);
|
||||||
|
expect(Number(tables[0]?.count ?? "0")).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second run at the command level too", async () => {
|
||||||
|
const url = await freshInstallation("clirerun");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
expect((await runBootstrapCli(url, ["--email", email], ownerPassword)).code).toBe(0);
|
||||||
|
|
||||||
|
const second = await runBootstrapCli(url, ["--email", email], "a different password");
|
||||||
|
expect(second.code).toBe(1);
|
||||||
|
expect(second.stderr).toContain("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||||
|
expect(second.stderr).not.toContain("a different password");
|
||||||
|
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("initial platform-owner bootstrap and email verification", () => {
|
||||||
|
/**
|
||||||
|
* The trusted-bootstrap verification exception (ADR 0038).
|
||||||
|
*
|
||||||
|
* A verified address became a precondition for holding a session in Phase 7C Pass 2. The initial
|
||||||
|
* owner is created on a freshly provisioned server, by an operator holding the database
|
||||||
|
* credentials, at a moment when SMTP may not exist — so requiring an email round trip here would
|
||||||
|
* produce a working installation nobody can ever sign in to. These scenarios exist to prove that
|
||||||
|
* the exception is real, that it is narrow, and that mail remains genuinely absent throughout.
|
||||||
|
*/
|
||||||
|
it("creates the owner already verified, proven in the database and in the browser", async () => {
|
||||||
|
const url = await freshInstallation("verifiedowner");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database state: the exact column Better Auth's own verification endpoint writes, so the row
|
||||||
|
// is indistinguishable from one that verified normally.
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(true);
|
||||||
|
|
||||||
|
// ...and the browser-facing consequence, which is the only thing that actually matters.
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
const session = await signIn(origin, email, ownerPassword);
|
||||||
|
expect(session.response.status).toBe(200);
|
||||||
|
expect(session.cookiePair).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs no outbound email configured, at bootstrap or at sign-in", async () => {
|
||||||
|
const url = await freshInstallation("nomailowner");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
// No SMTP host, no relay, no network: `createBootstrapIdentityCreator` constructs its own
|
||||||
|
// Better Auth instance with no `emailVerification` configuration at all.
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
// The API this suite starts configures no mail either, so nothing in this scenario could have
|
||||||
|
// sent or received a message.
|
||||||
|
expect(ownerApiConfig(url).mail).toBeUndefined();
|
||||||
|
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an adopted identity verified too, so bootstrap never yields an owner who cannot sign in", async () => {
|
||||||
|
const url = await freshInstallation("adoptverified");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
// An identity that exists before bootstrap runs — created here through the same trusted creator,
|
||||||
|
// which always writes `emailVerified: false`.
|
||||||
|
const first = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await first.run(email);
|
||||||
|
} finally {
|
||||||
|
await first.close();
|
||||||
|
}
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(client, 'update "ekklesia_core_user" set "email_verified" = false');
|
||||||
|
await safeQuery(client, 'delete from "ekklesia_core_platform_owner_bootstrap"');
|
||||||
|
await safeQuery(client, 'delete from "ekklesia_core_platform_owner"');
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(false);
|
||||||
|
|
||||||
|
const second = bootstrapAgainst(url);
|
||||||
|
let adopted: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
|
||||||
|
try {
|
||||||
|
adopted = await second.run(email);
|
||||||
|
} finally {
|
||||||
|
await second.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(adopted.identity).toBe("adopted");
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies only the owner, leaving every other account subject to the normal policy", async () => {
|
||||||
|
const url = await freshInstallation("narrowexception");
|
||||||
|
const ownerEmail = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const otherEmail = `member-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(ownerEmail);
|
||||||
|
// A second identity created through the identical trusted creator, but never bootstrapped.
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await createBootstrapIdentityCreator(identity.db)({
|
||||||
|
email: otherEmail,
|
||||||
|
name: "Ordinary Member",
|
||||||
|
password: ownerPassword,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await identity.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await emailVerifiedFlag(url, ownerEmail)).toBe(true);
|
||||||
|
expect(await emailVerifiedFlag(url, otherEmail)).toBe(false);
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
expect((await signIn(origin, ownerEmail, ownerPassword)).response.status).toBe(200);
|
||||||
|
// The exception did not widen: an ordinary account still has to verify, and on this
|
||||||
|
// mail-less installation it simply cannot sign in yet.
|
||||||
|
const refused = await signIn(origin, otherEmail, ownerPassword);
|
||||||
|
expect(refused.response.status).toBe(403);
|
||||||
|
expect(await refused.response.json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||||
|
expect(refused.cookiePair).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,11 +8,26 @@ const betterAuthMock = vi.hoisted(() => vi.fn());
|
|||||||
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
||||||
const serveMock = vi.hoisted(() => vi.fn());
|
const serveMock = vi.hoisted(() => vi.fn());
|
||||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||||
|
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||||
|
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
|
||||||
|
// so the loader answers a small consistent snapshot unless a test makes it fail.
|
||||||
|
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||||
|
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||||
|
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||||
|
);
|
||||||
|
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||||
|
});
|
||||||
|
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||||
|
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||||
|
});
|
||||||
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
|
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
|
||||||
// gets the real Hono application; only the one test that deliberately forces an app-construction
|
// gets the real Hono application; only the one test that deliberately forces an app-construction
|
||||||
// failure overrides this with `mockImplementationOnce`.
|
// failure overrides this with `mockImplementationOnce`.
|
||||||
@@ -30,6 +45,13 @@ import {
|
|||||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
|
||||||
|
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||||
|
import {
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||||
|
|
||||||
function validConfig() {
|
function validConfig() {
|
||||||
@@ -48,6 +70,21 @@ function validConfig() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one query behavior every fake pool in this file needs: startup reads the stored module
|
||||||
|
* enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module
|
||||||
|
* entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning
|
||||||
|
* an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database
|
||||||
|
* — not a way to skip the read. The tests that care about a *specific* configuration drive the real
|
||||||
|
* resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the
|
||||||
|
* real store (`core/tests/module-lifecycle-store.integration.ts`).
|
||||||
|
*/
|
||||||
|
function emptyQuery() {
|
||||||
|
return vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] });
|
||||||
|
}
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
async function createTemporaryModulesDirectory(): Promise<string> {
|
async function createTemporaryModulesDirectory(): Promise<string> {
|
||||||
@@ -67,7 +104,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 4,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -134,6 +171,8 @@ interface WorkingFixtureModuleOptions {
|
|||||||
* can be exercised deterministically without any sleep. */
|
* can be exercised deterministically without any sleep. */
|
||||||
readonly stopNeverSettles?: boolean;
|
readonly stopNeverSettles?: boolean;
|
||||||
readonly dependencies?: Record<string, unknown>;
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
/** Declares one protected display artifact without importing it. */
|
||||||
|
readonly withDisplay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
|
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
|
||||||
@@ -147,12 +186,43 @@ async function createWorkingFixtureModule(
|
|||||||
options: WorkingFixtureModuleOptions = {},
|
options: WorkingFixtureModuleOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const permissionId = `${id}.ping.read`;
|
const permissionId = `${id}.ping.read`;
|
||||||
|
const displayPermission = `${id}.display.view`;
|
||||||
|
const permissions = [
|
||||||
|
...(options.withRoute
|
||||||
|
? [{ baseline: "public" as const, description: "Ping.", id: permissionId }]
|
||||||
|
: []),
|
||||||
|
...(options.withDisplay
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "View the display.",
|
||||||
|
id: displayPermission,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
await createFixtureModule(modulesDirectory, id, {
|
await createFixtureModule(modulesDirectory, id, {
|
||||||
permissions: options.withRoute
|
entrypoints: {
|
||||||
? [{ baseline: "public", description: "Ping.", id: permissionId }]
|
server: "./dist/server/index.js",
|
||||||
: [],
|
...(options.withDisplay
|
||||||
|
? {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: displayPermission,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
permissions,
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
});
|
});
|
||||||
|
if (options.withDisplay) {
|
||||||
|
await mkdir(path.join(modulesDirectory, id, "dist/display"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(modulesDirectory, id, "dist/display/index.js"),
|
||||||
|
'throw new Error("display artifact must never execute during server startup");\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
const routeSource = options.withRoute
|
const routeSource = options.withRoute
|
||||||
? `http.route({
|
? `http.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -200,12 +270,13 @@ function fakeDiscoveredModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 4,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
@@ -359,7 +430,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
|
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
const { server } = mockSuccessfulServe();
|
const { server } = mockSuccessfulServe();
|
||||||
@@ -375,7 +446,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
|
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -392,10 +463,40 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
|
||||||
|
const query = emptyQuery();
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig());
|
||||||
|
query.mockClear();
|
||||||
|
|
||||||
|
const ready = await running.app.request(HEALTH_READY_PATH);
|
||||||
|
expect(ready.status).toBe(200);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
|
||||||
|
// cancels a stalled ping rather than the route merely abandoning it.
|
||||||
|
expect(query).toHaveBeenCalledWith(
|
||||||
|
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shutdown = running.shutdown();
|
||||||
|
const draining = await running.app.request(HEALTH_READY_PATH);
|
||||||
|
expect(draining.status).toBe(503);
|
||||||
|
expect(await draining.json()).toEqual({ code: "NOT_READY" });
|
||||||
|
// Draining is not dead: liveness still answers while in-flight requests finish.
|
||||||
|
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
await shutdown;
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
const { server } = mockSuccessfulServe();
|
const { server } = mockSuccessfulServe();
|
||||||
@@ -411,7 +512,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
it("closes an already-created database pool when identity construction fails during startup", async () => {
|
it("closes an already-created database pool when identity construction fails during startup", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockImplementation(() => {
|
betterAuthMock.mockImplementation(() => {
|
||||||
throw new Error("postgresql://admin:[email protected]/x");
|
throw new Error("postgresql://admin:[email protected]/x");
|
||||||
@@ -429,7 +530,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -467,7 +568,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -520,7 +621,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockFailingServe(new Error("EADDRINUSE"));
|
mockFailingServe(new Error("EADDRINUSE"));
|
||||||
@@ -584,7 +685,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
|||||||
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
|
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -621,7 +722,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
|||||||
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
|
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -673,7 +774,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
|||||||
async function startAndCaptureClientErrorHandler() {
|
async function startAndCaptureClientErrorHandler() {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
const { clientErrorHandler } = mockSuccessfulServe();
|
const { clientErrorHandler } = mockSuccessfulServe();
|
||||||
@@ -891,7 +992,7 @@ describe("Ekklesia API module permission composition at startup", () => {
|
|||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
@@ -927,18 +1028,83 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
betterAuthMock.mockReset();
|
betterAuthMock.mockReset();
|
||||||
drizzleAdapterMock.mockClear();
|
drizzleAdapterMock.mockClear();
|
||||||
serveMock.mockReset();
|
serveMock.mockReset();
|
||||||
|
loadDisplayHostAssetsMock.mockReset();
|
||||||
vi.mocked(createApiApp).mockClear();
|
vi.mocked(createApiApp).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: poolEnd, on: vi.fn() };
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
return { poolEnd };
|
return { poolEnd };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("fails on missing display-host assets before importing module code and still closes startup resources", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "announcements", recorder, {
|
||||||
|
withDisplay: true,
|
||||||
|
});
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
loadDisplayHostAssetsMock.mockRejectedValueOnce(new DisplayHostAssetError());
|
||||||
|
|
||||||
|
const failure = await startApi(validConfig(), {
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
}).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The display host browser assets are unavailable.",
|
||||||
|
name: "DisplayHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DisplayHostAssetError: The display host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
|
||||||
|
|
||||||
|
const failure = await startApi(validConfig(), {
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
}).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||||
|
mockPoolAndAuth();
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||||
|
|
||||||
|
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
|
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
@@ -1131,6 +1297,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
recorder.events.push("pool:end");
|
recorder.events.push("pool:end");
|
||||||
}),
|
}),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
|
query: emptyQuery(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
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 ModuleConfigurationRecord,
|
||||||
|
type ModuleConfigurationStore,
|
||||||
|
ModuleConfigurationStoreError,
|
||||||
|
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 { 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: 7,
|
||||||
|
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 ModuleRuntimeServices,
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ import { Client, type QueryResultRow } from "pg";
|
|||||||
import { afterAll, describe, expect, it } from "vitest";
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
import { createApiDatabase } from "../src/database.js";
|
import { createApiDatabase } from "../src/database.js";
|
||||||
import { startApi, type RunningApi } from "../src/server.js";
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
|
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
|
||||||
@@ -220,6 +220,11 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"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) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,6 +250,11 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
"core:0000_identity_core_tables",
|
"core:0000_identity_core_tables",
|
||||||
"core:0001_authorization_core_tables",
|
"core:0001_authorization_core_tables",
|
||||||
"core:0002_authorization_integrity",
|
"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",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
@@ -311,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
|
|||||||
return { userId: body.user.id };
|
return { userId: body.user.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
|
||||||
|
*
|
||||||
|
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
|
||||||
|
* module database access rather than identity, so it completes that step directly rather than
|
||||||
|
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
|
||||||
|
* `email-verification.integration.ts`.
|
||||||
|
*/
|
||||||
|
async function markEmailVerified(url: string, email: string): Promise<void> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
||||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
@@ -439,6 +470,10 @@ describe.sequential("live API — module database explicit migration ownership",
|
|||||||
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
|
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
|
||||||
1,
|
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 {
|
} finally {
|
||||||
await dropDatabase(database.name);
|
await dropDatabase(database.name);
|
||||||
}
|
}
|
||||||
@@ -495,11 +530,12 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
|||||||
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||||
|
|
||||||
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
||||||
|
await markEmailVerified(database.url, "[email protected]");
|
||||||
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
||||||
expect(signedUp.userId).toBeDefined();
|
expect(signedUp.userId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("authorized read reflects real PostgreSQL state, exposes exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => {
|
it("authorized read reflects real PostgreSQL state, exposes the exact scoped handler context, and the handler's database is reference-equal to start's", async () => {
|
||||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||||
@@ -509,7 +545,20 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
|||||||
const readHandlerEvent = [...events]
|
const readHandlerEvent = [...events]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||||
expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]);
|
// The complete handler-context surface, asserted exactly so a new field is a deliberate change
|
||||||
|
// here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime`
|
||||||
|
// (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono
|
||||||
|
// Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or
|
||||||
|
// credential — remains unreachable.
|
||||||
|
expect(readHandlerEvent?.contextKeys).toEqual([
|
||||||
|
"config",
|
||||||
|
"moduleId",
|
||||||
|
"params",
|
||||||
|
"principal",
|
||||||
|
"realtime",
|
||||||
|
"services",
|
||||||
|
"storage",
|
||||||
|
]);
|
||||||
expect(readHandlerEvent?.services).toBeDefined();
|
expect(readHandlerEvent?.services).toBeDefined();
|
||||||
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
|
import {
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
DISPLAY_SERVICE_WORKER_PATH,
|
||||||
|
ModuleDisplayConfigurationError,
|
||||||
|
moduleDisplayDescriptorUrl,
|
||||||
|
moduleDisplayPageUrl,
|
||||||
|
} from "../src/module-display-routes.js";
|
||||||
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||||
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const displayPermission = "announcements.display.view";
|
||||||
|
|
||||||
|
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||||
|
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
||||||
|
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
||||||
|
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ModuleFixture {
|
||||||
|
readonly display?: boolean;
|
||||||
|
readonly id?: string;
|
||||||
|
readonly ui?: boolean;
|
||||||
|
readonly version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
|
||||||
|
const id = fixture.id ?? "announcements";
|
||||||
|
const version = fixture.version ?? "1.4.2";
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: `@test/module-${id}`,
|
||||||
|
type: "module",
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
|
...(fixture.display
|
||||||
|
? {
|
||||||
|
display: {
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
manifestVersion: 7,
|
||||||
|
name: `${id} module`,
|
||||||
|
permissions: fixture.display
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: `View the ${id} display.`,
|
||||||
|
id: `${id}.display.view`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
|
||||||
|
);
|
||||||
|
if (fixture.ui) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fixture.display) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallOptions extends ModuleFixture {
|
||||||
|
readonly assets?: DisplayHostAssets;
|
||||||
|
readonly evaluator?: AuthorizationEvaluator;
|
||||||
|
readonly granted?: readonly string[];
|
||||||
|
readonly principal?: EkklesiaPrincipal;
|
||||||
|
/** The API builds publication from the eligible/running set. `false` models a module that was
|
||||||
|
* discovered but disabled or dependency-blocked before runtime startup. */
|
||||||
|
readonly published?: boolean;
|
||||||
|
readonly withAssets?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly app: Hono;
|
||||||
|
readonly modules: readonly DiscoveredModule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeServices(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeAuthorizationStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
} as unknown as AuthorizationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(options: InstallOptions): Promise<Installation> {
|
||||||
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
|
||||||
|
temporaryDirectories.push(modulesDirectory);
|
||||||
|
await createModule(modulesDirectory, options);
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
const granted = new Set(options.granted ?? []);
|
||||||
|
const evaluator =
|
||||||
|
options.evaluator ??
|
||||||
|
createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
||||||
|
platformOwners: { isPlatformOwner: async () => false },
|
||||||
|
});
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
const publishedModules = options.published === false ? [] : modules;
|
||||||
|
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
|
store: fakeAuthorizationStore(),
|
||||||
|
},
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||||
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||||
|
: {}),
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeServices(),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
moduleUi,
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, modules };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(): EkklesiaPrincipal {
|
||||||
|
return createAuthenticatedPrincipal(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module display routes — authorization and descriptor", () => {
|
||||||
|
it("gives an unpaired viewer the inert shell but never the descriptor", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const [page, descriptor] = await Promise.all([
|
||||||
|
app.request(moduleDisplayPageUrl("announcements")),
|
||||||
|
app.request(moduleDisplayDescriptorUrl("announcements")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The page is deliberately open: a television nobody has paired has no identity to present, and
|
||||||
|
// a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing —
|
||||||
|
// the same module-agnostic bytes every display gets, with no module id and no data.
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(await page.text()).toBe(displayHostAssets.html);
|
||||||
|
|
||||||
|
// Authorization is entirely in the descriptor, and it has not moved.
|
||||||
|
expect(descriptor.status).toBe(403);
|
||||||
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
||||||
|
const { app } = await install({ display: true, principal: signedIn() });
|
||||||
|
|
||||||
|
// Being signed in is not being permitted, and the descriptor is where that is decided.
|
||||||
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
version: "1.4.2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await app.request(moduleDisplayPageUrl("announcements"));
|
||||||
|
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||||
|
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
expect(await page.text()).toBe(displayHostAssets.html);
|
||||||
|
expect(await descriptor.json()).toEqual({
|
||||||
|
viewer: { kind: "authenticated", userId: viewerId },
|
||||||
|
module: {
|
||||||
|
contributions: [],
|
||||||
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
|
display: {
|
||||||
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
|
},
|
||||||
|
id: "announcements",
|
||||||
|
page: { url: moduleDisplayPageUrl("announcements") },
|
||||||
|
permissions: [displayPermission],
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||||
|
version: "1.4.2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(descriptor.headers.get("content-type")).toContain("application/json");
|
||||||
|
expect(descriptor.headers.get("vary")).toContain("Origin");
|
||||||
|
expect(descriptor.headers.get("vary")).toContain("Cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
|
||||||
|
const authorize = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ allowed: true })
|
||||||
|
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
|
||||||
|
const evaluator: AuthorizationEvaluator = { authorize };
|
||||||
|
const { app } = await install({ display: true, evaluator, principal: signedIn() });
|
||||||
|
|
||||||
|
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(authorize).toHaveBeenCalledTimes(2);
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("vary")).toContain("Origin");
|
||||||
|
expect(response.headers.get("vary")).toContain("Cookie");
|
||||||
|
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
|
||||||
|
expect(body).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the outer page stable when the internal artifact version changes", () => {
|
||||||
|
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
|
||||||
|
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module display routes — role and lifecycle separation", () => {
|
||||||
|
it("keeps a display-only module out of the primary UI inventory", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect(await inventory.json()).toEqual({ modules: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not give a dashboard-only module a display route", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn(), ui: true });
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
|
||||||
|
modules: [{ id: "announcements" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes both roles independently when a module declares both", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
ui: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
|
modules: Array<{ display: { url: string } | null }>;
|
||||||
|
}
|
||||||
|
).modules,
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
published: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||||
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers only the exact stable page and descriptor paths", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const route of [
|
||||||
|
"/display",
|
||||||
|
"/display/announcements/",
|
||||||
|
"/display/announcements/settings",
|
||||||
|
"/api/v1/module-displays",
|
||||||
|
"/api/v1/module-displays/announcements/",
|
||||||
|
"/api/v1/module-displays/announcements/settings",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(route)).status, route).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails composition when a display exists but its host assets were not supplied", async () => {
|
||||||
|
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
|
||||||
|
ModuleDisplayConfigurationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module display host asset publication", () => {
|
||||||
|
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.script);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
expect(await notModified.text()).toBe("");
|
||||||
|
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
|
||||||
|
expect(notModified.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
|
||||||
|
for (const header of [
|
||||||
|
`W/${displayHostAssets.scriptEtag}`,
|
||||||
|
"*",
|
||||||
|
`"stale", ${displayHostAssets.scriptEtag}`,
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": header },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
header,
|
||||||
|
).toBe(304);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": '"stale"' },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
|
||||||
|
"/assets/ekklesia-display/index.html",
|
||||||
|
"/assets/ekklesia-display/secret.txt",
|
||||||
|
"/assets/ekklesia-display/../secret.txt",
|
||||||
|
"/api/v1/module-ui/announcements/1.4.2/management",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses identical non-interpolated HTML for every eligible module", async () => {
|
||||||
|
const first = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
const second = await install({
|
||||||
|
display: true,
|
||||||
|
granted: ["signage.display.view"],
|
||||||
|
id: "signage",
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
|
||||||
|
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
|
||||||
|
expect(firstHtml).toBe(secondHtml);
|
||||||
|
expect(firstHtml).not.toContain("announcements");
|
||||||
|
expect(secondHtml).not.toContain("signage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
||||||
|
const source = createDisplayHostAssets(
|
||||||
|
"<html>display host</html>",
|
||||||
|
"export const host = 1;\n",
|
||||||
|
"export const worker = 1;\n",
|
||||||
|
);
|
||||||
|
const mutable = { ...source };
|
||||||
|
const { app } = await install({ assets: mutable, display: true });
|
||||||
|
mutable.html = "<html>mutated secret</html>";
|
||||||
|
mutable.script = "throw new Error('mutated secret');";
|
||||||
|
|
||||||
|
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
install({
|
||||||
|
assets: { ...source, scriptEtag: '"stale-validator"' },
|
||||||
|
display: true,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the generic host script when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the offline service worker from the path whose directory is its scope", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
|
||||||
|
|
||||||
|
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
|
||||||
|
// else. The Dashboard is never controlled by it (ADR 0043).
|
||||||
|
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
|
||||||
|
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates the service worker with its own validator, never the host script's", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
|
||||||
|
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(wrongValidator.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the service worker when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be reached through a neighbouring or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
|
||||||
|
"/display/sw.js/",
|
||||||
|
"/sw.js",
|
||||||
|
"/assets/ekklesia-display/sw.js",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,13 +17,23 @@ import {
|
|||||||
type PermissionRegistry,
|
type PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
canonicalizeVaryHeader,
|
canonicalizeVaryHeader,
|
||||||
mergedVaryHeader,
|
mergedVaryHeader,
|
||||||
ModuleRouteConfigurationError,
|
ModuleRouteConfigurationError,
|
||||||
} from "../src/module-http-routes.js";
|
} from "../src/module-http-routes.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -50,14 +60,17 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
|
/** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
|
||||||
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
||||||
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
||||||
* suite further down overrides it explicitly to prove the contract itself. */
|
* suite further down overrides them explicitly to prove the contract itself. */
|
||||||
function moduleRoute(
|
function moduleRoute(
|
||||||
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
||||||
|
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
||||||
|
maxBodyBytes?: number;
|
||||||
|
},
|
||||||
): RegisteredModuleHttpRoute {
|
): RegisteredModuleHttpRoute {
|
||||||
return Object.freeze({ maxBodyBytes: 1024, ...fields });
|
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||||
@@ -105,7 +118,15 @@ function buildModuleApp(options: {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
||||||
moduleRoutes: options.routes,
|
moduleRoutes: options.routes,
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: services,
|
moduleServices: services,
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
});
|
});
|
||||||
@@ -331,13 +352,20 @@ describe("module route handler adaptation", () => {
|
|||||||
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||||
expect(capturedRequest).toBeInstanceOf(Request);
|
expect(capturedRequest).toBeInstanceOf(Request);
|
||||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
||||||
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
|
// Exactly config, moduleId, params, principal, services, storage — and nothing else. `config`
|
||||||
// or configuration reaches a module handler.
|
// is this module's own frozen configuration snapshot (ADR 0029) and `storage` its own scoped
|
||||||
|
// object storage (ADR 0030); a Hono Context, the authorization store or evaluator, raw
|
||||||
|
// PostgreSQL, the storage provider, an S3 endpoint or credential, platform configuration, and
|
||||||
|
// another module's configuration or storage all remain unreachable, which is what this
|
||||||
|
// exact-shape assertion exists to keep true.
|
||||||
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
||||||
|
"config",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"params",
|
"params",
|
||||||
"principal",
|
"principal",
|
||||||
|
"realtime",
|
||||||
"services",
|
"services",
|
||||||
|
"storage",
|
||||||
]);
|
]);
|
||||||
expect(capturedContext).toMatchObject({
|
expect(capturedContext).toMatchObject({
|
||||||
moduleId: "calendar",
|
moduleId: "calendar",
|
||||||
@@ -350,6 +378,14 @@ describe("module route handler adaptation", () => {
|
|||||||
expect(Object.isFrozen(capturedContext)).toBe(true);
|
expect(Object.isFrozen(capturedContext)).toBe(true);
|
||||||
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
||||||
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
||||||
|
expect(Object.isFrozen(capturedContext?.config)).toBe(true);
|
||||||
|
// This module declared no configuration, so its snapshot is empty rather than absent — module
|
||||||
|
// code never has to branch on whether the platform supplied one.
|
||||||
|
expect(capturedContext?.config.keys()).toEqual([]);
|
||||||
|
// Likewise storage: this module declared no buckets, so it gets a frozen facade that refuses
|
||||||
|
// every one rather than `undefined`.
|
||||||
|
expect(Object.isFrozen(capturedContext?.storage)).toBe(true);
|
||||||
|
expect(capturedContext?.storage.buckets()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives static and root routes a frozen, empty params object", async () => {
|
it("gives static and root routes a frozen, empty params object", async () => {
|
||||||
@@ -1605,3 +1641,375 @@ describe("module route overlap can never reach mounting", () => {
|
|||||||
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit, bounded streaming uploads (ADR 0042).
|
||||||
|
*
|
||||||
|
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
|
||||||
|
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
|
||||||
|
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
|
||||||
|
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
|
||||||
|
*/
|
||||||
|
describe("module route streamed body limits", () => {
|
||||||
|
const oneMebibyte = 1024 * 1024;
|
||||||
|
|
||||||
|
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
|
||||||
|
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
|
||||||
|
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
|
||||||
|
let sent = 0;
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
if (sent >= size) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = Math.min(chunkSize, size - sent);
|
||||||
|
sent += next;
|
||||||
|
controller.enqueue(new Uint8Array(next).fill(97));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamingApp(options: {
|
||||||
|
readonly handler: ModuleRouteHandler;
|
||||||
|
readonly maxBodyBytes: number;
|
||||||
|
readonly evaluator?: AuthorizationEvaluator;
|
||||||
|
}) {
|
||||||
|
return buildModuleApp({
|
||||||
|
...(options.evaluator ? { evaluator: options.evaluator } : {}),
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "streamed",
|
||||||
|
handler: options.handler,
|
||||||
|
maxBodyBytes: options.maxBodyBytes,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
|
||||||
|
* "buffered then handed over". */
|
||||||
|
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
|
||||||
|
return async (request) => {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body) {
|
||||||
|
return Response.json({ bytes: 0 });
|
||||||
|
}
|
||||||
|
const reader = body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
received.bytes += value.byteLength;
|
||||||
|
}
|
||||||
|
return Response.json({ bytes: received.bytes });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts a body of exactly the declared limit", async () => {
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: await countingHandler(received),
|
||||||
|
maxBodyBytes: oneMebibyte,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(oneMebibyte),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ bytes: oneMebibyte });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const counting = await countingHandler(received);
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(counting);
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(2048, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-length": String(1025), origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
|
||||||
|
// byte of the upload reached module code.
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(received.bytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
|
||||||
|
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
|
||||||
|
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: await countingHandler(received),
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096, 32),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-length": "8", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
// It stopped promptly rather than draining the whole upload first.
|
||||||
|
expect(received.bytes).toBeLessThanOrEqual(96);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
|
||||||
|
// A limit a handler can swallow is not a limit.
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
try {
|
||||||
|
await request.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
// Deliberately ignored, exactly as a careless module might.
|
||||||
|
}
|
||||||
|
return Response.json({ ok: "handler won" });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096, 32),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
|
||||||
|
let released: (() => void) | undefined;
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
released = resolve;
|
||||||
|
});
|
||||||
|
let firstChunkSeen = false;
|
||||||
|
let secondChunkQueued = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
async pull(controller) {
|
||||||
|
if (!secondChunkQueued) {
|
||||||
|
secondChunkQueued = true;
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
|
||||||
|
// buffering platform would deadlock here rather than reach the assertion below.
|
||||||
|
await finished;
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
const first = await reader.read();
|
||||||
|
firstChunkSeen = !first.done;
|
||||||
|
released?.();
|
||||||
|
for (;;) {
|
||||||
|
const next = await reader.read();
|
||||||
|
if (next.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(firstChunkSeen).toBe(true);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases an aborted upload without taking the platform down", async () => {
|
||||||
|
const aborted = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
controller.error(new Error("client went away"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
await request.arrayBuffer();
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: aborted,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases a body the handler never read", async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
pull(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: async () => Response.json({ ok: true }),
|
||||||
|
maxBodyBytes: oneMebibyte,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||||
|
};
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
evaluator,
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "streamed",
|
||||||
|
handler,
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: "a".repeat(65),
|
||||||
|
headers: { "content-length": "65", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(declaredOversize.status).toBe(413);
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A within-limit body from a denied caller is refused by authorization, and the handler — the
|
||||||
|
// only thing that ever reads a byte in streamed mode — never runs.
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(32, 8),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects an untrusted Origin before any body handling", async () => {
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: untrustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: async () => Response.json({ ok: true }),
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: "secret-payload-".repeat(64),
|
||||||
|
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
|
||||||
|
expect(body).not.toContain("secret-payload");
|
||||||
|
expect(body).not.toContain("64");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still materializes a buffered body before the handler runs", async () => {
|
||||||
|
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
|
||||||
|
// body is already there and already within its declared allowance.
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
const text = await request.text();
|
||||||
|
return Response.json({ length: text.length });
|
||||||
|
});
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler,
|
||||||
|
maxBodyBytes: 128,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/events",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||||
|
body: chunkedBody(128, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(withinLimit.status).toBe(200);
|
||||||
|
expect(await withinLimit.json()).toEqual({ length: 128 });
|
||||||
|
|
||||||
|
const overLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||||
|
body: chunkedBody(129, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(overLimit.status).toBe(413);
|
||||||
|
// A buffered route's handler never sees an over-limit request at all.
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
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 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: 7,
|
||||||
|
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 ModuleRuntimeServices,
|
||||||
|
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 ModuleDatabase,
|
||||||
|
type ModuleRealtimePublisher,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type PermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
discoverModules,
|
||||||
|
type ModuleRealtimeRegistry,
|
||||||
|
type ModuleRealtimeSubscription,
|
||||||
|
startDiscoveredModuleRuntime,
|
||||||
|
type StartedModuleRuntime,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { type ServerType, serve } from "@hono/node-server";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { mountModuleRealtimeRoutes } from "../src/module-realtime-routes.js";
|
||||||
|
|
||||||
|
const allowedUserId = parseEkklesiaUserId("99999999-9999-4999-8999-999999999999");
|
||||||
|
const deniedUserId = parseEkklesiaUserId("77777777-7777-4777-8777-777777777777");
|
||||||
|
const stableDeliveryUrl =
|
||||||
|
"https://objects.example.test/announcements/slides/welcome.png?X-Amz-Signature=stable";
|
||||||
|
const TRANSPORT_DEADLINE_MS = 2_000;
|
||||||
|
|
||||||
|
async function withinTransportDeadline<Value>(
|
||||||
|
operation: Promise<Value>,
|
||||||
|
message: string,
|
||||||
|
onTimeout: () => void = () => undefined,
|
||||||
|
): Promise<Value> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
onTimeout();
|
||||||
|
reject(new Error(message));
|
||||||
|
}, TRANSPORT_DEADLINE_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTransportCondition(predicate: () => boolean, message: string): Promise<void> {
|
||||||
|
let cancelled = false;
|
||||||
|
await withinTransportDeadline(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
const inspect = (): void => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (predicate()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(inspect, 5);
|
||||||
|
};
|
||||||
|
inspect();
|
||||||
|
}),
|
||||||
|
message,
|
||||||
|
() => {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixtureControl {
|
||||||
|
readonly events: string[];
|
||||||
|
publisher?: ModuleRealtimePublisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveEvent {
|
||||||
|
readonly event: "snapshot" | "update";
|
||||||
|
readonly payload: unknown;
|
||||||
|
readonly sequence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveSseConnection {
|
||||||
|
readonly #abort: AbortController;
|
||||||
|
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
#buffer = "";
|
||||||
|
|
||||||
|
constructor(response: Response, abort: AbortController) {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Realtime integration response had no body.");
|
||||||
|
}
|
||||||
|
this.#abort = abort;
|
||||||
|
this.#reader = response.body.getReader();
|
||||||
|
}
|
||||||
|
|
||||||
|
async readEvent(): Promise<LiveEvent> {
|
||||||
|
for (;;) {
|
||||||
|
const boundary = this.#buffer.indexOf("\n\n");
|
||||||
|
if (boundary >= 0) {
|
||||||
|
const block = this.#buffer.slice(0, boundary);
|
||||||
|
this.#buffer = this.#buffer.slice(boundary + 2);
|
||||||
|
if (block.startsWith(":")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const event = block
|
||||||
|
.split("\n")
|
||||||
|
.find((line) => line.startsWith("event: "))
|
||||||
|
?.slice("event: ".length);
|
||||||
|
const data = block
|
||||||
|
.split("\n")
|
||||||
|
.find((line) => line.startsWith("data: "))
|
||||||
|
?.slice("data: ".length);
|
||||||
|
if ((event !== "snapshot" && event !== "update") || data === undefined) {
|
||||||
|
throw new Error("Realtime integration received an invalid event.");
|
||||||
|
}
|
||||||
|
const envelope = JSON.parse(data) as { payload?: unknown; sequence?: unknown };
|
||||||
|
if (!Number.isSafeInteger(envelope.sequence) || (envelope.sequence as number) <= 0) {
|
||||||
|
throw new Error("Realtime integration received an invalid sequence.");
|
||||||
|
}
|
||||||
|
return { event, payload: envelope.payload, sequence: envelope.sequence as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.#readWithinDeadline();
|
||||||
|
if (result.done) {
|
||||||
|
throw new Error("Realtime integration stream closed before the expected event.");
|
||||||
|
}
|
||||||
|
this.#buffer += new TextDecoder().decode(result.value, { stream: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async expectClosed(): Promise<void> {
|
||||||
|
const result = await this.#readWithinDeadline();
|
||||||
|
expect(result.done).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.#abort.abort();
|
||||||
|
await withinTransportDeadline(
|
||||||
|
// Abort and cancel race by design; a rejection means one of them already released the
|
||||||
|
// transport. A deadline still propagates and fails the test.
|
||||||
|
this.#reader.cancel().catch(() => undefined),
|
||||||
|
"Realtime integration client cancellation timed out.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #readWithinDeadline() {
|
||||||
|
return withinTransportDeadline(
|
||||||
|
this.#reader.read(),
|
||||||
|
"Realtime integration read timed out.",
|
||||||
|
() => this.#abort.abort(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFixtureModule(control: FixtureControl): Promise<{
|
||||||
|
readonly directory: string;
|
||||||
|
readonly symbol: symbol;
|
||||||
|
}> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-native-realtime-"));
|
||||||
|
const moduleDirectory = path.join(directory, "announcements");
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
const symbol = Symbol.for(`ekklesia.native-realtime.${Date.now()}.${Math.random()}`);
|
||||||
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = control;
|
||||||
|
const expression = `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
name: "@test/module-announcements",
|
||||||
|
type: "module",
|
||||||
|
version: "0.1.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
|
id: "announcements",
|
||||||
|
manifestVersion: 7,
|
||||||
|
name: "Announcements fixture",
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: "announcements.slideshow.view",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
version: "0.1.0",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
`export default {
|
||||||
|
id: "announcements",
|
||||||
|
version: "0.1.0",
|
||||||
|
register({ realtime }) {
|
||||||
|
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
|
||||||
|
${expression}.events.push("register");
|
||||||
|
},
|
||||||
|
start({ realtime }) {
|
||||||
|
${expression}.publisher = realtime;
|
||||||
|
realtime.publish("state", {
|
||||||
|
mode: "on",
|
||||||
|
currentSlide: 0,
|
||||||
|
previousSlide: -1,
|
||||||
|
slides: [${JSON.stringify(stableDeliveryUrl)}]
|
||||||
|
});
|
||||||
|
${expression}.events.push("start");
|
||||||
|
},
|
||||||
|
stop() { ${expression}.events.push("stop"); }
|
||||||
|
};\n`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return { directory, symbol };
|
||||||
|
}
|
||||||
|
|
||||||
|
function services(): ModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: async (headers) => {
|
||||||
|
const cookie = headers.get("cookie");
|
||||||
|
if (cookie === "ekklesia-session=allowed") {
|
||||||
|
return createAuthenticatedPrincipal(allowedUserId);
|
||||||
|
}
|
||||||
|
if (cookie === "ekklesia-session=denied") {
|
||||||
|
return createAuthenticatedPrincipal(deniedUserId);
|
||||||
|
}
|
||||||
|
return ANONYMOUS_PRINCIPAL;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorization(): AuthorizationEvaluator {
|
||||||
|
return createAuthorizationEvaluator({
|
||||||
|
grants: { hasActiveGrant: async ({ userId }) => userId === allowedUserId },
|
||||||
|
platformOwners: { isPlatformOwner: async () => false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindLiveServer(
|
||||||
|
runtime: StartedModuleRuntime,
|
||||||
|
registry: PermissionRegistry,
|
||||||
|
): Promise<{
|
||||||
|
readonly activeSubscriptions: () => number;
|
||||||
|
readonly origin: string;
|
||||||
|
readonly server: ServerType;
|
||||||
|
}> {
|
||||||
|
const app = new Hono();
|
||||||
|
let activeSubscriptions = 0;
|
||||||
|
const observedRealtime: ModuleRealtimeRegistry = {
|
||||||
|
channels: runtime.realtime.channels,
|
||||||
|
closeSubscriptions: () => runtime.realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (moduleId) => runtime.realtime.publisherFor(moduleId),
|
||||||
|
subscribe: (moduleId, channel): ModuleRealtimeSubscription => {
|
||||||
|
const subscription = runtime.realtime.subscribe(moduleId, channel);
|
||||||
|
activeSubscriptions += 1;
|
||||||
|
let closed = false;
|
||||||
|
return Object.freeze({
|
||||||
|
close: (): void => {
|
||||||
|
if (!closed) {
|
||||||
|
closed = true;
|
||||||
|
activeSubscriptions -= 1;
|
||||||
|
}
|
||||||
|
subscription.close();
|
||||||
|
},
|
||||||
|
read: () => subscription.read(),
|
||||||
|
snapshot: subscription.snapshot,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mountModuleRealtimeRoutes(app, {
|
||||||
|
evaluator: authorization(),
|
||||||
|
realtime: observedRealtime,
|
||||||
|
registry,
|
||||||
|
runtime: identityRuntime(),
|
||||||
|
});
|
||||||
|
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
|
||||||
|
|
||||||
|
let pendingServer: ServerType | undefined;
|
||||||
|
const server = await withinTransportDeadline(
|
||||||
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
|
pendingServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, () =>
|
||||||
|
resolve(pendingServer as ServerType),
|
||||||
|
);
|
||||||
|
pendingServer.once("error", reject);
|
||||||
|
}),
|
||||||
|
"Realtime integration server bind timed out.",
|
||||||
|
() => pendingServer?.close(),
|
||||||
|
);
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Realtime integration server did not bind a TCP port.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeSubscriptions: () => activeSubscriptions,
|
||||||
|
origin: `http://127.0.0.1:${address.port}`,
|
||||||
|
server,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(
|
||||||
|
origin: string,
|
||||||
|
cookie: string,
|
||||||
|
): Promise<{
|
||||||
|
readonly connection: LiveSseConnection;
|
||||||
|
readonly response: Response;
|
||||||
|
}> {
|
||||||
|
const abort = new AbortController();
|
||||||
|
const response = await withinTransportDeadline(
|
||||||
|
fetch(`${origin}/api/v1/module-realtime/announcements/state`, {
|
||||||
|
headers: { cookie },
|
||||||
|
signal: abort.signal,
|
||||||
|
}),
|
||||||
|
"Realtime integration connection timed out.",
|
||||||
|
() => abort.abort(),
|
||||||
|
);
|
||||||
|
return { connection: new LiveSseConnection(response, abort), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeServer(server: ServerType): Promise<void> {
|
||||||
|
await withinTransportDeadline(
|
||||||
|
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||||
|
"Realtime integration server close timed out.",
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
server as ServerType & {
|
||||||
|
closeAllConnections?: () => void;
|
||||||
|
}
|
||||||
|
).closeAllConnections?.(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<{ readonly body: unknown; readonly response: Response }> {
|
||||||
|
const abort = new AbortController();
|
||||||
|
const operation = (async (): Promise<{ readonly body: unknown; readonly response: Response }> => {
|
||||||
|
const response = await fetch(url, { ...init, signal: abort.signal });
|
||||||
|
const body = (await response.json()) as unknown;
|
||||||
|
return { body, response };
|
||||||
|
})();
|
||||||
|
return withinTransportDeadline(operation, "Realtime integration finite request timed out.", () =>
|
||||||
|
abort.abort(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("native realtime over a live Node HTTP server", () => {
|
||||||
|
it("loads a real module and proves auth, snapshot, fanout, update, reconnect, and shutdown", async () => {
|
||||||
|
const control: FixtureControl = { events: [] };
|
||||||
|
const fixture = await createFixtureModule(control);
|
||||||
|
let runtime: StartedModuleRuntime | undefined;
|
||||||
|
let server: ServerType | undefined;
|
||||||
|
const connections: LiveSseConnection[] = [];
|
||||||
|
const failures: unknown[] = [];
|
||||||
|
try {
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: fixture.directory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
runtime = await startDiscoveredModuleRuntime(modules, services());
|
||||||
|
expect(control.events).toEqual(["register", "start"]);
|
||||||
|
const live = await bindLiveServer(runtime, composePlatformPermissionRegistry(modules));
|
||||||
|
server = live.server;
|
||||||
|
|
||||||
|
const anonymous = await requestJson(
|
||||||
|
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||||
|
);
|
||||||
|
expect(anonymous.response.status).toBe(403);
|
||||||
|
expect(anonymous.body).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
const missingPermission = await requestJson(
|
||||||
|
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||||
|
{ headers: { cookie: "ekklesia-session=denied" } },
|
||||||
|
);
|
||||||
|
expect(missingPermission.response.status).toBe(403);
|
||||||
|
|
||||||
|
const first = await connect(live.origin, "ekklesia-session=allowed");
|
||||||
|
const second = await connect(live.origin, "ekklesia-session=allowed");
|
||||||
|
connections.push(first.connection, second.connection);
|
||||||
|
expect(first.response.status).toBe(200);
|
||||||
|
expect(first.response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||||
|
|
||||||
|
const expectedInitial = {
|
||||||
|
currentSlide: 0,
|
||||||
|
mode: "on",
|
||||||
|
previousSlide: -1,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
};
|
||||||
|
expect(await first.connection.readEvent()).toEqual({
|
||||||
|
event: "snapshot",
|
||||||
|
payload: expectedInitial,
|
||||||
|
sequence: 1,
|
||||||
|
});
|
||||||
|
expect(await second.connection.readEvent()).toEqual({
|
||||||
|
event: "snapshot",
|
||||||
|
payload: expectedInitial,
|
||||||
|
sequence: 1,
|
||||||
|
});
|
||||||
|
expect(live.activeSubscriptions()).toBe(2);
|
||||||
|
|
||||||
|
control.publisher?.publish("state", {
|
||||||
|
currentSlide: 1,
|
||||||
|
mode: "on",
|
||||||
|
previousSlide: 0,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
});
|
||||||
|
const expectedUpdate = {
|
||||||
|
event: "update",
|
||||||
|
payload: {
|
||||||
|
currentSlide: 1,
|
||||||
|
mode: "on",
|
||||||
|
previousSlide: 0,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
},
|
||||||
|
sequence: 2,
|
||||||
|
};
|
||||||
|
expect(await first.connection.readEvent()).toEqual(expectedUpdate);
|
||||||
|
expect(await second.connection.readEvent()).toEqual(expectedUpdate);
|
||||||
|
|
||||||
|
await first.connection.close();
|
||||||
|
await waitForTransportCondition(
|
||||||
|
() => live.activeSubscriptions() === 1,
|
||||||
|
"Realtime integration subscriber cleanup timed out.",
|
||||||
|
);
|
||||||
|
control.publisher?.publish("state", {
|
||||||
|
currentSlide: 2,
|
||||||
|
mode: "off",
|
||||||
|
previousSlide: 1,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
});
|
||||||
|
expect(await second.connection.readEvent()).toMatchObject({ event: "update", sequence: 3 });
|
||||||
|
|
||||||
|
const reconnected = await connect(live.origin, "ekklesia-session=allowed");
|
||||||
|
connections.push(reconnected.connection);
|
||||||
|
expect(await reconnected.connection.readEvent()).toEqual({
|
||||||
|
event: "snapshot",
|
||||||
|
payload: {
|
||||||
|
currentSlide: 2,
|
||||||
|
mode: "off",
|
||||||
|
previousSlide: 1,
|
||||||
|
slides: [stableDeliveryUrl],
|
||||||
|
},
|
||||||
|
sequence: 3,
|
||||||
|
});
|
||||||
|
expect(live.activeSubscriptions()).toBe(2);
|
||||||
|
|
||||||
|
const secondClosed = second.connection.expectClosed();
|
||||||
|
const reconnectedClosed = reconnected.connection.expectClosed();
|
||||||
|
runtime.realtime.closeSubscriptions();
|
||||||
|
await Promise.all([secondClosed, reconnectedClosed]);
|
||||||
|
await waitForTransportCondition(
|
||||||
|
() => live.activeSubscriptions() === 0,
|
||||||
|
"Realtime integration shutdown cleanup timed out.",
|
||||||
|
);
|
||||||
|
// Stream shutdown happens before ordinary HTTP drain in production. A publisher must remain
|
||||||
|
// usable until that drain completes so an already-running command can publish its result.
|
||||||
|
expect(() => control.publisher?.publish("state", null)).not.toThrow();
|
||||||
|
|
||||||
|
const afterShutdown = await requestJson(
|
||||||
|
`${live.origin}/api/v1/module-realtime/announcements/state`,
|
||||||
|
{ headers: { cookie: "ekklesia-session=allowed" } },
|
||||||
|
);
|
||||||
|
expect(afterShutdown.response.status).toBe(503);
|
||||||
|
expect(afterShutdown.body).toEqual({ code: "REALTIME_UNAVAILABLE" });
|
||||||
|
await runtime.stop();
|
||||||
|
expect(() => control.publisher?.publish("state", null)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
runtime?.realtime.closeSubscriptions();
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
const connectionCleanup = await Promise.allSettled(
|
||||||
|
connections.map((connection) => connection.close()),
|
||||||
|
);
|
||||||
|
for (const result of connectionCleanup) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
failures.push(result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (server) {
|
||||||
|
try {
|
||||||
|
await closeServer(server);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runtime) {
|
||||||
|
try {
|
||||||
|
await runtime.stop();
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete (globalThis as unknown as Record<PropertyKey, unknown>)[fixture.symbol];
|
||||||
|
try {
|
||||||
|
await rm(fixture.directory, { force: true, recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw failures[0];
|
||||||
|
}
|
||||||
|
expect(control.events).toEqual(["register", "start", "stop"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
ModuleRealtimeError,
|
||||||
|
moduleIdSchema,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
parsePermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { createPermissionRegistry, type PermissionRegistry } from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import { type ModuleRealtimeRegistry, StagedModuleRealtimeRegistry } from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
MODULE_REALTIME_KEEPALIVE_MS,
|
||||||
|
MODULE_REALTIME_MAX_CONNECTION_AGE_MS,
|
||||||
|
ModuleRealtimeConfigurationError,
|
||||||
|
moduleRealtimeBaseUrl,
|
||||||
|
mountModuleRealtimeRoutes,
|
||||||
|
} from "../src/module-realtime-routes.js";
|
||||||
|
|
||||||
|
const moduleId = moduleIdSchema.parse("announcements");
|
||||||
|
const permission = parsePermissionId("announcements.slideshow.view");
|
||||||
|
const userId = parseEkklesiaUserId("88888888-8888-4888-8888-888888888888");
|
||||||
|
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||||
|
|
||||||
|
function permissionRegistry(): PermissionRegistry {
|
||||||
|
return createPermissionRegistry([
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: permission,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function realtimeFixture(initialPayload: unknown = { mode: "on" }): {
|
||||||
|
readonly publisher: ReturnType<StagedModuleRealtimeRegistry["publisherFor"]>;
|
||||||
|
readonly realtime: ModuleRealtimeRegistry;
|
||||||
|
} {
|
||||||
|
const staged = new StagedModuleRealtimeRegistry();
|
||||||
|
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||||
|
registration.realtime.channel({ channel: "state", permission });
|
||||||
|
registration.commit();
|
||||||
|
const publisher = staged.publisherFor(moduleId);
|
||||||
|
publisher.publish("state", initialPayload as never);
|
||||||
|
return { publisher, realtime: staged.publish() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appFor(
|
||||||
|
realtime: ModuleRealtimeRegistry,
|
||||||
|
options: {
|
||||||
|
readonly allowed?: boolean;
|
||||||
|
readonly keepaliveMs?: number;
|
||||||
|
readonly maxConnectionAgeMs?: number;
|
||||||
|
readonly signedIn?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
options.signedIn === false ? ANONYMOUS_PRINCIPAL : createAuthenticatedPrincipal(userId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
mountModuleRealtimeRoutes(app, {
|
||||||
|
evaluator: {
|
||||||
|
authorize: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ allowed: options.signedIn !== false && options.allowed !== false }),
|
||||||
|
},
|
||||||
|
...(options.keepaliveMs === undefined ? {} : { keepaliveMs: options.keepaliveMs }),
|
||||||
|
...(options.maxConnectionAgeMs === undefined
|
||||||
|
? {}
|
||||||
|
: { maxConnectionAgeMs: options.maxConnectionAgeMs }),
|
||||||
|
realtime,
|
||||||
|
registry: permissionRegistry(),
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
reader.read(),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error("Realtime test read timed out.")), 1_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return result.done ? "" : new TextDecoder().decode(result.value);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module realtime HTTP transport", () => {
|
||||||
|
it("builds one server-owned root and rejects an unenforceable channel snapshot", () => {
|
||||||
|
expect(moduleRealtimeBaseUrl("announcements")).toBe("/api/v1/module-realtime/announcements");
|
||||||
|
expect(MODULE_REALTIME_KEEPALIVE_MS).toBe(15_000);
|
||||||
|
expect(MODULE_REALTIME_MAX_CONNECTION_AGE_MS).toBe(300_000);
|
||||||
|
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
expect(() =>
|
||||||
|
mountModuleRealtimeRoutes(new Hono(), {
|
||||||
|
evaluator: { authorize: vi.fn() },
|
||||||
|
realtime,
|
||||||
|
registry: createPermissionRegistry([]),
|
||||||
|
runtime: { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn() },
|
||||||
|
}),
|
||||||
|
).toThrow(ModuleRealtimeConfigurationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies anonymous and unauthorized callers before allocating a subscription", async () => {
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||||
|
const observed: ModuleRealtimeRegistry = {
|
||||||
|
channels: realtime.channels,
|
||||||
|
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
|
||||||
|
const anonymous = await appFor(observed, { signedIn: false }).request(endpoint);
|
||||||
|
expect(anonymous.status).toBe(403);
|
||||||
|
expect(await anonymous.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
expect(subscribe).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const unauthorized = await appFor(observed, { allowed: false }).request(endpoint);
|
||||||
|
expect(unauthorized.status).toBe(403);
|
||||||
|
expect(await unauthorized.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
expect(subscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses Hono's implicit HEAD handling without allocating a stream subscription", async () => {
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
|
||||||
|
const observed: ModuleRealtimeRegistry = {
|
||||||
|
channels: realtime.channels,
|
||||||
|
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await appFor(observed).request(endpoint, { method: "HEAD" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(subscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens an authorized snapshot-first stream with proxy-safe headers and unchanged URLs", async () => {
|
||||||
|
const deliveryUrl =
|
||||||
|
"https://objects.example.test/slides/welcome.png?X-Amz-Signature=byte-identical";
|
||||||
|
const { realtime } = realtimeFixture({
|
||||||
|
currentSlide: 0,
|
||||||
|
mode: "on",
|
||||||
|
previousSlide: -1,
|
||||||
|
slides: [deliveryUrl],
|
||||||
|
});
|
||||||
|
const response = await appFor(realtime).request(endpoint);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("x-accel-buffering")).toBe("no");
|
||||||
|
expect(response.headers.get("vary")).toBe("Origin, Cookie");
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
expect(reader).toBeDefined();
|
||||||
|
const snapshot = await readChunk(reader as ReadableStreamDefaultReader<Uint8Array>);
|
||||||
|
expect(snapshot).toContain("event: snapshot\n");
|
||||||
|
expect(snapshot).toContain('"sequence":1');
|
||||||
|
expect(snapshot).toContain(deliveryUrl);
|
||||||
|
expect(snapshot).not.toContain("\nid:");
|
||||||
|
await reader?.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams ordered updates, emits transport-only keepalives, and closes on shutdown", async () => {
|
||||||
|
const { publisher, realtime } = realtimeFixture({ currentSlide: 0 });
|
||||||
|
const response = await appFor(realtime, { keepaliveMs: 5 }).request(endpoint);
|
||||||
|
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
|
||||||
|
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||||
|
publisher.publish("state", { currentSlide: 1 });
|
||||||
|
const update = await readChunk(reader);
|
||||||
|
expect(update).toContain("event: update");
|
||||||
|
expect(update).toContain('"sequence":2');
|
||||||
|
expect(update).toContain('"currentSlide":1');
|
||||||
|
expect(await readChunk(reader)).toBe(": keepalive\n\n");
|
||||||
|
|
||||||
|
const closed = reader.read();
|
||||||
|
realtime.closeSubscriptions();
|
||||||
|
await expect(closed).resolves.toEqual({ done: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a stream at the host-owned maximum age so reconnect reauthorizes", async () => {
|
||||||
|
const { realtime } = realtimeFixture({ currentSlide: 0 });
|
||||||
|
let activeSubscriptions = 0;
|
||||||
|
const observed: ModuleRealtimeRegistry = {
|
||||||
|
channels: realtime.channels,
|
||||||
|
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||||
|
subscribe: (owner, channel) => {
|
||||||
|
const subscription = realtime.subscribe(owner, channel);
|
||||||
|
activeSubscriptions += 1;
|
||||||
|
let closed = false;
|
||||||
|
return Object.freeze({
|
||||||
|
close: () => {
|
||||||
|
if (!closed) {
|
||||||
|
closed = true;
|
||||||
|
activeSubscriptions -= 1;
|
||||||
|
}
|
||||||
|
subscription.close();
|
||||||
|
},
|
||||||
|
read: () => subscription.read(),
|
||||||
|
snapshot: subscription.snapshot,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await appFor(observed, {
|
||||||
|
keepaliveMs: 1_000,
|
||||||
|
maxConnectionAgeMs: 25,
|
||||||
|
}).request(endpoint);
|
||||||
|
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
|
||||||
|
expect(await readChunk(reader)).toContain("event: snapshot");
|
||||||
|
expect(activeSubscriptions).toBe(1);
|
||||||
|
expect(await readChunk(reader)).toBe("");
|
||||||
|
expect(activeSubscriptions).toBe(0);
|
||||||
|
const replacement = realtime.subscribe(moduleId, "state");
|
||||||
|
replacement.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose unknown module/channel combinations", async () => {
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
const app = appFor(realtime);
|
||||||
|
expect((await app.request("/api/v1/module-realtime/calendar/state")).status).toBe(404);
|
||||||
|
expect((await app.request("/api/v1/module-realtime/announcements/other")).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps subscriber-capacity and unexpected allocation failures to fixed non-secret responses", async () => {
|
||||||
|
const { realtime } = realtimeFixture();
|
||||||
|
const observed = (failure: unknown): ModuleRealtimeRegistry => ({
|
||||||
|
channels: realtime.channels,
|
||||||
|
closeSubscriptions: () => realtime.closeSubscriptions(),
|
||||||
|
publisherFor: (owner) => realtime.publisherFor(owner),
|
||||||
|
subscribe: () => {
|
||||||
|
throw failure;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const atCapacity = await appFor(
|
||||||
|
observed(new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT")),
|
||||||
|
).request(endpoint);
|
||||||
|
expect(atCapacity.status).toBe(503);
|
||||||
|
expect(await atCapacity.json()).toEqual({ code: "REALTIME_SUBSCRIBER_LIMIT" });
|
||||||
|
|
||||||
|
const detail = "secret-token-and-machine-path";
|
||||||
|
const unavailable = await appFor(observed(new Error(detail))).request(endpoint);
|
||||||
|
expect(unavailable.status).toBe(503);
|
||||||
|
const body = await unavailable.text();
|
||||||
|
expect(body).toBe('{"code":"REALTIME_UNAVAILABLE"}');
|
||||||
|
expect(body).not.toContain(detail);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
parseEkklesiaUserId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationStore,
|
||||||
|
createAuthorizationEvaluator,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
discoverModules,
|
||||||
|
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: 7,
|
||||||
|
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: ModuleRuntimeServices = 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,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
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,
|
||||||
|
} 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: 7,
|
||||||
|
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: ModuleRuntimeServices = Object.freeze({
|
||||||
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
|
});
|
||||||
|
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
||||||
|
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: { evaluator, registry: permissionRegistry, store },
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: services,
|
||||||
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, modules, modulesDirectory, permissionRegistry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(): EkklesiaPrincipal {
|
||||||
|
return createAuthenticatedPrincipal(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inventoryOf(app: Hono): Promise<{
|
||||||
|
readonly body: string;
|
||||||
|
readonly modules: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
readonly response: Response;
|
||||||
|
}> {
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
const body = await response.clone().text();
|
||||||
|
const parsed = (await response.json()) as { modules?: ReadonlyArray<Record<string, unknown>> };
|
||||||
|
return { body, modules: parsed.modules ?? [], response };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module UI inventory — what it lists", () => {
|
||||||
|
it("describes a publishable module with exactly the fields a host needs", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [], withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules, response } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(modules).toEqual([
|
||||||
|
{
|
||||||
|
contributions: [],
|
||||||
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
|
display: null,
|
||||||
|
id: "example",
|
||||||
|
permissions: [],
|
||||||
|
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||||
|
ui: { url: "/api/v1/module-ui/example/0.1.0" },
|
||||||
|
version: "0.1.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds all host URLs with the server's own canonical helpers", async () => {
|
||||||
|
const { app, modules: discovered } = await install({
|
||||||
|
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
const module = discovered[0] as DiscoveredModule;
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
// Not "looks like the right shape" — byte-identical to what the helpers produce, so a second
|
||||||
|
// hand-written template string anywhere would fail here.
|
||||||
|
expect(modules[0]?.ui).toEqual({
|
||||||
|
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
|
||||||
|
});
|
||||||
|
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
|
||||||
|
expect(modules[0]?.realtime).toEqual({
|
||||||
|
baseUrl: moduleRealtimeBaseUrl(module.manifest.id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a module that declares no UI entrypoint", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "calendar" }, { id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.map((module) => module.id)).toEqual(["example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty, not an error, when no installed module provides a UI", async () => {
|
||||||
|
const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() });
|
||||||
|
|
||||||
|
const { modules, response } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(modules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders modules deterministically, matching the publication registry", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{ id: "signage", withUi: true },
|
||||||
|
{ id: "announcements", withUi: true },
|
||||||
|
{ id: "calendar", withUi: true },
|
||||||
|
],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await inventoryOf(app);
|
||||||
|
const second = await inventoryOf(app);
|
||||||
|
|
||||||
|
// Discovery order, which is sorted; the same on every request.
|
||||||
|
expect(first.modules.map((module) => module.id)).toEqual([
|
||||||
|
"announcements",
|
||||||
|
"calendar",
|
||||||
|
"signage",
|
||||||
|
]);
|
||||||
|
expect(second.modules.map((module) => module.id)).toEqual(
|
||||||
|
first.modules.map((module) => module.id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaks no filesystem path, package root, or other manifest internal", async () => {
|
||||||
|
const {
|
||||||
|
app,
|
||||||
|
modules: discovered,
|
||||||
|
modulesDirectory,
|
||||||
|
} = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [], withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string;
|
||||||
|
|
||||||
|
const { body, modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(body).not.toContain(modulesDirectory);
|
||||||
|
expect(body).not.toContain(artifactPath);
|
||||||
|
expect(body).not.toContain(tmpdir());
|
||||||
|
expect(body).not.toContain("dist");
|
||||||
|
expect(body).not.toContain("@test/module-example");
|
||||||
|
expect(body).not.toContain("ekklesia.module.json");
|
||||||
|
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
||||||
|
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||||
|
"api",
|
||||||
|
"contributions",
|
||||||
|
"display",
|
||||||
|
"id",
|
||||||
|
"permissions",
|
||||||
|
"realtime",
|
||||||
|
"ui",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no navigation or presentation metadata", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body } = await inventoryOf(app);
|
||||||
|
|
||||||
|
// ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module
|
||||||
|
// declaration. The manifest's own `name` and `description` must not leak into it by accident.
|
||||||
|
for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) {
|
||||||
|
expect(body, field).not.toContain(`"${field}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module UI inventory — authentication", () => {
|
||||||
|
it("refuses an anonymous caller", async () => {
|
||||||
|
const { app } = await install({ fixtures: [{ id: "example", withUi: true }] });
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells an anonymous caller nothing about what is installed", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{ id: "example", withUi: true },
|
||||||
|
{ id: "calendar", withUi: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
|
||||||
|
|
||||||
|
expect(body).not.toContain("example");
|
||||||
|
expect(body).not.toContain("calendar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits any signed-in user without needing an explicit grant", async () => {
|
||||||
|
// `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell
|
||||||
|
// that rendered nothing until an operator granted every user a permission would be unusable.
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
grants: [],
|
||||||
|
principal: signedIn(),
|
||||||
|
platformOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is never cached and varies on the caller's identity", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
const vary = response.headers.get("vary")?.toLowerCase() ?? "";
|
||||||
|
expect(vary).toContain("origin");
|
||||||
|
expect(vary).toContain("cookie");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module UI inventory — permission hints", () => {
|
||||||
|
const readPermission = { baseline: "restricted" as const, id: "example.records.read" };
|
||||||
|
const writePermission = { baseline: "restricted" as const, id: "example.records.write" };
|
||||||
|
|
||||||
|
it("returns only the permissions the server actually granted this principal", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||||
|
grants: ["example.records.read"],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules[0]?.permissions).toEqual(["example.records.read"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none of them for a signed-in user holding no grant", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||||
|
grants: [],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
// Never a default of true, and never the module's full declared surface.
|
||||||
|
expect(modules[0]?.permissions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a baseline that needs no grant at all", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{
|
||||||
|
id: "example",
|
||||||
|
permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission],
|
||||||
|
withUi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grants: [],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules[0]?.permissions).toEqual(["example.records.list"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours the platform-owner bypass, exactly like a real request would", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
||||||
|
grants: [],
|
||||||
|
platformOwner: true,
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes hints to each module's own declared permissions", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [
|
||||||
|
{ id: "example", permissions: [readPermission], withUi: true },
|
||||||
|
{
|
||||||
|
id: "calendar",
|
||||||
|
permissions: [{ baseline: "restricted", id: "calendar.events.read" }],
|
||||||
|
withUi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grants: ["example.records.read", "calendar.events.read"],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
const byId = new Map(modules.map((module) => [module.id, module.permissions]));
|
||||||
|
// Neither module learns anything about the other's authorization state, and neither is told
|
||||||
|
// about Core's own permissions.
|
||||||
|
expect(byId.get("example")).toEqual(["example.records.read"]);
|
||||||
|
expect(byId.get("calendar")).toEqual(["calendar.events.read"]);
|
||||||
|
expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage");
|
||||||
|
expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", permissions: [readPermission], withUi: true }],
|
||||||
|
grantsUnavailable: true,
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
|
||||||
|
// A hint that silently read "denied" during an outage would blank the UI and look like a
|
||||||
|
// permissions problem.
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module UI inventory — it is not a module management API", () => {
|
||||||
|
it("routes no method other than GET", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
const response = await app.request(MODULE_UI_INVENTORY_PATH, { method });
|
||||||
|
expect(response.status, method).toBe(404);
|
||||||
|
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses to serve an artifact under the collection path", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [{ id: "example", withUi: true }],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) {
|
||||||
|
const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`);
|
||||||
|
expect(response.status, suffix).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inventory is how a consuming module learns what was contributed to its own capabilities
|
||||||
|
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
|
||||||
|
*/
|
||||||
|
describe("module UI inventory — contributions", () => {
|
||||||
|
const signage = {
|
||||||
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||||
|
id: "signage",
|
||||||
|
permissions: [],
|
||||||
|
withUi: true,
|
||||||
|
};
|
||||||
|
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||||
|
contributions: [
|
||||||
|
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
|
||||||
|
],
|
||||||
|
id: "announcements",
|
||||||
|
permissions: [],
|
||||||
|
withUi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the providing module the contribution and its server-produced URL", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
const consumer = modules.find((module) => module.id === "signage");
|
||||||
|
|
||||||
|
expect(consumer?.contributions).toEqual([
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
id: "announcements.signage-slide",
|
||||||
|
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds a contribution whose provider is outside its declared range", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements("^2.0.0")],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
|
||||||
|
const { app, modulesDirectory } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body, modules } = await inventoryOf(app);
|
||||||
|
const consumer = modules.find((module) => module.id === "signage");
|
||||||
|
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
|
||||||
|
|
||||||
|
expect(body).not.toContain(modulesDirectory);
|
||||||
|
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
|
||||||
|
"capability",
|
||||||
|
"id",
|
||||||
|
"url",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
|
||||||
|
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
|
||||||
|
export function testDashboardHostAssets(): DashboardHostAssets {
|
||||||
|
return createDashboardHostAssets(
|
||||||
|
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||||
|
"export const dashboardHost = true;\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
DashboardConfiguration,
|
||||||
|
DashboardGroupRecord,
|
||||||
|
DashboardPlacementRecord,
|
||||||
|
DashboardStore,
|
||||||
|
SetDashboardPlacementInput,
|
||||||
|
UpsertDashboardGroupInput,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
|
import { DashboardStoreError } from "@ekklesia/core/dashboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory `DashboardStore` for tests that exercise **routing and resolution**, not persistence.
|
||||||
|
*
|
||||||
|
* It reproduces only the two behaviours those tests depend on — a placement may not name a group
|
||||||
|
* that does not exist, and deleting a group ungroups its modules (the schema's
|
||||||
|
* `on delete set null`) — and deliberately reproduces nothing else. Everything the real store owns
|
||||||
|
* that this double would only be guessing at (identifier grammar, label trimming and bounds,
|
||||||
|
* ordering bounds, transactional behaviour, and the actual `on delete set null` constraint) is
|
||||||
|
* proven against real PostgreSQL in `core/tests/dashboard-store.integration.ts` instead, so this
|
||||||
|
* double can never quietly stand in for a persistence guarantee nobody checked.
|
||||||
|
*/
|
||||||
|
export function createInMemoryDashboardStore(
|
||||||
|
seed: Partial<DashboardConfiguration> = {},
|
||||||
|
): DashboardStore {
|
||||||
|
const groups = new Map<string, DashboardGroupRecord>(
|
||||||
|
(seed.groups ?? []).map((group) => [group.id, { ...group }] as const),
|
||||||
|
);
|
||||||
|
const placements = new Map<string, DashboardPlacementRecord>(
|
||||||
|
(seed.placements ?? []).map((placement) => [placement.moduleId, { ...placement }] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = <Record extends { readonly sortOrder: number }>(
|
||||||
|
records: readonly Record[],
|
||||||
|
key: (record: Record) => string,
|
||||||
|
): readonly Record[] =>
|
||||||
|
Object.freeze(
|
||||||
|
[...records].sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.sortOrder - right.sortOrder ||
|
||||||
|
(key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
async deleteGroup(groupId: string): Promise<void> {
|
||||||
|
if (!groups.delete(groupId)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
for (const [moduleId, placement] of placements) {
|
||||||
|
if (placement.groupId === groupId) {
|
||||||
|
placements.set(moduleId, { ...placement, groupId: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async readConfiguration(): Promise<DashboardConfiguration> {
|
||||||
|
return Object.freeze({
|
||||||
|
groups: sorted([...groups.values()], (group) => group.id),
|
||||||
|
placements: sorted([...placements.values()], (placement) => placement.moduleId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
|
||||||
|
if (input.groupId !== null && !groups.has(input.groupId)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
const record: DashboardPlacementRecord = Object.freeze({
|
||||||
|
groupId: input.groupId,
|
||||||
|
moduleId: input.moduleId,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
});
|
||||||
|
placements.set(input.moduleId, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
|
||||||
|
const record: DashboardGroupRecord = Object.freeze({
|
||||||
|
id: input.id,
|
||||||
|
label: input.label.trim(),
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
});
|
||||||
|
groups.set(input.id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
|||||||
|
import type { ModuleEnablementRecord } from "@ekklesia/contracts";
|
||||||
|
import type {
|
||||||
|
DiscoveredModule,
|
||||||
|
ModuleLifecycleConfiguration,
|
||||||
|
ModuleLifecycleSnapshot,
|
||||||
|
ModuleLifecycleStore,
|
||||||
|
SetModuleEnabledInput,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { ModuleLifecycleStoreError, resolveModuleEligibility } from "@ekklesia/core/modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory `ModuleLifecycleStore` for tests that exercise **routing and resolution**, not
|
||||||
|
* persistence.
|
||||||
|
*
|
||||||
|
* It reproduces only what those tests depend on — records survive a write and read back in
|
||||||
|
* module-id order, and an invalid identifier is rejected — and deliberately reproduces nothing
|
||||||
|
* else. The real table's constraints, the upsert's conflict target, and the fact that a record for
|
||||||
|
* an undeployed module is retained are proven against real PostgreSQL in
|
||||||
|
* `core/tests/module-lifecycle-store.integration.ts`, so this double can never quietly stand in for
|
||||||
|
* a persistence guarantee nobody checked.
|
||||||
|
*/
|
||||||
|
export function createInMemoryModuleLifecycleStore(
|
||||||
|
seed: readonly ModuleEnablementRecord[] = [],
|
||||||
|
): ModuleLifecycleStore {
|
||||||
|
const records = new Map<string, boolean>(
|
||||||
|
seed.map((record) => [record.moduleId, record.enabled] as const),
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
|
||||||
|
return Object.freeze({
|
||||||
|
modules: Object.freeze(
|
||||||
|
[...records.entries()]
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
.map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
|
||||||
|
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) {
|
||||||
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
|
||||||
|
}
|
||||||
|
records.set(input.moduleId, input.enabled);
|
||||||
|
return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered
|
||||||
|
* inventory and the stored configuration, then record what would have started — rather than
|
||||||
|
* hand-writing one, so a test can never assert against a snapshot the real composition would never
|
||||||
|
* produce.
|
||||||
|
*/
|
||||||
|
export function moduleLifecycleSnapshotFor(
|
||||||
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
|
configuration: ModuleLifecycleConfiguration = { modules: [] },
|
||||||
|
): ModuleLifecycleSnapshot {
|
||||||
|
const resolution = resolveModuleEligibility(discoveredModules, configuration);
|
||||||
|
return Object.freeze({
|
||||||
|
discoveredModules,
|
||||||
|
eligibility: resolution.entries,
|
||||||
|
startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit
|
||||||
|
* "no modules" statement the required option asks for, never an implicit default. */
|
||||||
|
export function emptyModuleLifecycle(): {
|
||||||
|
readonly snapshot: ModuleLifecycleSnapshot;
|
||||||
|
readonly store: ModuleLifecycleStore;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor([]),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
type ModuleStorage,
|
||||||
|
type ModuleStorageBody,
|
||||||
|
ModuleStorageError,
|
||||||
|
type ModuleStorageListing,
|
||||||
|
type ModuleStorageListOptions,
|
||||||
|
type ModuleStorageObject,
|
||||||
|
type ModuleStorageObjectMetadata,
|
||||||
|
type ModuleStoragePutOptions,
|
||||||
|
type ModuleStorageReadUrl,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import type { DiscoveredModule, ModuleStorageLookup } from "@ekklesia/core/modules";
|
||||||
|
import { createModuleStorageLookup, type StorageProvider } from "@ekklesia/core/storage";
|
||||||
|
|
||||||
|
/** Builds and drains Web streams without depending on DOM-only helper types, which this package's
|
||||||
|
* TypeScript configuration deliberately does not load. */
|
||||||
|
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(bytes);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
total += value.byteLength;
|
||||||
|
}
|
||||||
|
const merged = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
merged.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredObject {
|
||||||
|
readonly bytes: Uint8Array;
|
||||||
|
readonly contentType?: string;
|
||||||
|
readonly lastModified: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory `StorageProvider` for tests that exercise **wiring, scoping, and routing** rather
|
||||||
|
* than the S3 protocol.
|
||||||
|
*
|
||||||
|
* It implements the same interface the real S3 provider does, so everything above the provider seam
|
||||||
|
* — binding resolution, the scoped facade, lifecycle eligibility, route wiring — is exercised
|
||||||
|
* unchanged. What it deliberately does *not* reproduce is the protocol itself: real SeaweedFS
|
||||||
|
* semantics (non-idempotent `CreateBucket`, `NoSuchKey` vs `NoSuchBucket`, ETag quoting, streaming
|
||||||
|
* bodies) are proven against a live server in `core/tests/module-storage.integration.ts`, so this
|
||||||
|
* double can never quietly stand in for a provider guarantee nobody checked.
|
||||||
|
*
|
||||||
|
* It keys everything by **physical** bucket, exactly as the real provider does, which is what lets a
|
||||||
|
* test assert that two modules declaring the same logical name really did land in different
|
||||||
|
* namespaces.
|
||||||
|
*/
|
||||||
|
export function createInMemoryStorageProvider(): StorageProvider & {
|
||||||
|
readonly buckets: ReadonlyMap<string, Map<string, StoredObject>>;
|
||||||
|
ensureBucketCalls: number;
|
||||||
|
} {
|
||||||
|
const buckets = new Map<string, Map<string, StoredObject>>();
|
||||||
|
const state = { ensureBucketCalls: 0 };
|
||||||
|
|
||||||
|
const objectsIn = (physicalBucket: string): Map<string, StoredObject> => {
|
||||||
|
const bucket = buckets.get(physicalBucket);
|
||||||
|
if (!bucket) {
|
||||||
|
throw new ModuleStorageError("STORAGE_BUCKET_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadataFor = (key: string, stored: StoredObject): ModuleStorageObjectMetadata =>
|
||||||
|
Object.freeze({
|
||||||
|
contentLength: stored.bytes.byteLength,
|
||||||
|
key,
|
||||||
|
lastModified: stored.lastModified,
|
||||||
|
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider: StorageProvider = {
|
||||||
|
async close(): Promise<void> {
|
||||||
|
// Nothing to release.
|
||||||
|
},
|
||||||
|
async createReadUrl(
|
||||||
|
physicalBucket: string,
|
||||||
|
key: string,
|
||||||
|
urlOptions: { readonly expiresInSeconds: number; readonly downloadFilename?: string },
|
||||||
|
): Promise<ModuleStorageReadUrl> {
|
||||||
|
// Keyed by physical bucket like everything else here, so a route test can prove the URL a
|
||||||
|
// handler received belongs to that module's namespace and not another's.
|
||||||
|
return Object.freeze({
|
||||||
|
expiresAt: new Date(Date.now() + urlOptions.expiresInSeconds * 1000),
|
||||||
|
url: `https://delivery.test/${physicalBucket}/${encodeURIComponent(key)}?token=fake`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async delete(physicalBucket: string, key: string): Promise<void> {
|
||||||
|
objectsIn(physicalBucket).delete(key);
|
||||||
|
},
|
||||||
|
async ensureBucket(physicalBucket: string): Promise<void> {
|
||||||
|
state.ensureBucketCalls += 1;
|
||||||
|
if (!buckets.has(physicalBucket)) {
|
||||||
|
buckets.set(physicalBucket, new Map());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async get(physicalBucket: string, key: string): Promise<ModuleStorageObject> {
|
||||||
|
const stored = objectsIn(physicalBucket).get(key);
|
||||||
|
if (!stored) {
|
||||||
|
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...metadataFor(key, stored),
|
||||||
|
body: streamOf(stored.bytes),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async head(physicalBucket: string, key: string): Promise<ModuleStorageObjectMetadata> {
|
||||||
|
const stored = objectsIn(physicalBucket).get(key);
|
||||||
|
if (!stored) {
|
||||||
|
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
|
||||||
|
}
|
||||||
|
return metadataFor(key, stored);
|
||||||
|
},
|
||||||
|
async list(
|
||||||
|
physicalBucket: string,
|
||||||
|
options: ModuleStorageListOptions,
|
||||||
|
): Promise<ModuleStorageListing> {
|
||||||
|
const entries = [...objectsIn(physicalBucket).entries()]
|
||||||
|
.filter(([key]) => options.prefix === undefined || key.startsWith(options.prefix))
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
||||||
|
return Object.freeze({
|
||||||
|
objects: Object.freeze(entries.map(([key, stored]) => metadataFor(key, stored))),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async probe(): Promise<void> {
|
||||||
|
// Always reachable.
|
||||||
|
},
|
||||||
|
async put(
|
||||||
|
physicalBucket: string,
|
||||||
|
key: string,
|
||||||
|
body: ModuleStorageBody,
|
||||||
|
options: ModuleStoragePutOptions,
|
||||||
|
): Promise<ModuleStorageObjectMetadata> {
|
||||||
|
const bytes =
|
||||||
|
typeof body === "string"
|
||||||
|
? new TextEncoder().encode(body)
|
||||||
|
: body instanceof Uint8Array
|
||||||
|
? body
|
||||||
|
: await collect(body as ReadableStream<Uint8Array>);
|
||||||
|
const stored: StoredObject = {
|
||||||
|
bytes,
|
||||||
|
lastModified: new Date(0),
|
||||||
|
...(options.contentType === undefined ? {} : { contentType: options.contentType }),
|
||||||
|
};
|
||||||
|
objectsIn(physicalBucket).set(key, stored);
|
||||||
|
return metadataFor(key, stored);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.assign(provider, {
|
||||||
|
buckets: buckets as ReadonlyMap<string, Map<string, StoredObject>>,
|
||||||
|
get ensureBucketCalls(): number {
|
||||||
|
return state.ensureBucketCalls;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleStorageOption {
|
||||||
|
readonly snapshots: ModuleStorageLookup;
|
||||||
|
readonly providerConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the `createApiApp` storage option the same way `startApi` does — resolve every module's
|
||||||
|
* declared buckets against one provider and one prefix — rather than hand-writing a map, so a test
|
||||||
|
* can never assert against a shape the real composition would not produce.
|
||||||
|
*/
|
||||||
|
export function moduleStorageOptionFor(
|
||||||
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
|
provider?: StorageProvider,
|
||||||
|
): ModuleStorageOption {
|
||||||
|
return {
|
||||||
|
providerConfigured: provider !== undefined,
|
||||||
|
snapshots: createModuleStorageLookup(
|
||||||
|
provider,
|
||||||
|
"ekklesia",
|
||||||
|
discoveredModules.map((module) => ({
|
||||||
|
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
|
||||||
|
moduleId: module.manifest.id,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The storage option for a `createApiApp` caller that exercises no storage at all — the explicit
|
||||||
|
* "this installation has no object storage" statement the required option asks for. */
|
||||||
|
export function emptyModuleStorage(): ModuleStorageOption {
|
||||||
|
return { providerConfigured: false, snapshots: new Map<string, ModuleStorage>() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ApiHealthOptions } from "../../src/health-routes.js";
|
||||||
|
|
||||||
|
/** Readiness inputs for a composition whose tests do not exercise health: the database answers and
|
||||||
|
* module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */
|
||||||
|
export function readyHealth(): ApiHealthOptions {
|
||||||
|
return {
|
||||||
|
checkDatabase: async () => undefined,
|
||||||
|
isModuleStartupComplete: () => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,14 @@ import { defineConfig } from "drizzle-kit";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"],
|
schema: [
|
||||||
|
"./src/identity/schema.ts",
|
||||||
|
"./src/authorization/schema.ts",
|
||||||
|
"./src/dashboard/schema.ts",
|
||||||
|
"./src/modules/lifecycle-schema.ts",
|
||||||
|
"./src/modules/configuration-schema.ts",
|
||||||
|
"./src/devices/schema.ts",
|
||||||
|
],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
migrations: {
|
migrations: {
|
||||||
prefix: "index",
|
prefix: "index",
|
||||||
|
|||||||
@@ -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,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
@@ -19,6 +19,36 @@
|
|||||||
"destructive": {
|
"destructive": {
|
||||||
"mode": "none"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,41 @@
|
|||||||
"when": 1785104670124,
|
"when": 1785104670124,
|
||||||
"tag": "0002_authorization_integrity",
|
"tag": "0002_authorization_integrity",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -15,6 +15,14 @@
|
|||||||
"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"
|
||||||
@@ -34,6 +42,18 @@
|
|||||||
"./database": {
|
"./database": {
|
||||||
"types": "./dist/database/index.d.ts",
|
"types": "./dist/database/index.d.ts",
|
||||||
"import": "./dist/database/index.js"
|
"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": {
|
||||||
@@ -45,17 +65,23 @@
|
|||||||
"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",
|
"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",
|
"auth": "1.6.23",
|
||||||
"drizzle-kit": "0.31.10",
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import {
|
|||||||
type AuthorizationScope,
|
type AuthorizationScope,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
|
isDevicePrincipal,
|
||||||
permissionBaselineSchema,
|
permissionBaselineSchema,
|
||||||
safeParseAuthorizationScope,
|
safeParseAuthorizationScope,
|
||||||
safeParsePermissionId,
|
safeParsePermissionId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js";
|
import type {
|
||||||
|
AuthorizationDevicePort,
|
||||||
|
AuthorizationGrantPort,
|
||||||
|
AuthorizationPlatformOwnerPort,
|
||||||
|
} from "./ports.js";
|
||||||
|
|
||||||
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
|
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
|
||||||
|
|
||||||
@@ -54,6 +59,10 @@ export interface AuthorizationEvaluator {
|
|||||||
export interface CreateAuthorizationEvaluatorOptions {
|
export interface CreateAuthorizationEvaluatorOptions {
|
||||||
readonly grants: AuthorizationGrantPort;
|
readonly grants: AuthorizationGrantPort;
|
||||||
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
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
|
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
|
||||||
* real wall clock; production composition must leave this unset. */
|
* real wall clock; production composition must leave this unset. */
|
||||||
readonly now?: () => Date;
|
readonly now?: () => Date;
|
||||||
@@ -100,17 +109,14 @@ export function createAuthorizationEvaluator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsedBaseline.data === "authenticated") {
|
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)
|
return isAuthenticatedPrincipal(request.principal)
|
||||||
? AUTHORIZATION_ALLOWED
|
? AUTHORIZATION_ALLOWED
|
||||||
: AUTHORIZATION_DENIED;
|
: AUTHORIZATION_DENIED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsedBaseline.data === "restricted": requires an authenticated principal AND
|
|
||||||
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
|
|
||||||
if (!isAuthenticatedPrincipal(request.principal)) {
|
|
||||||
return AUTHORIZATION_DENIED;
|
|
||||||
}
|
|
||||||
|
|
||||||
let scope: AuthorizationScope | undefined;
|
let scope: AuthorizationScope | undefined;
|
||||||
if (request.scope !== undefined) {
|
if (request.scope !== undefined) {
|
||||||
const parsedScope = safeParseAuthorizationScope(request.scope);
|
const parsedScope = safeParseAuthorizationScope(request.scope);
|
||||||
@@ -120,6 +126,34 @@ export function createAuthorizationEvaluator(
|
|||||||
scope = parsedScope.data;
|
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;
|
const userId = request.principal.userId;
|
||||||
let isOwner: boolean;
|
let isOwner: boolean;
|
||||||
let hasGrant: boolean;
|
let hasGrant: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./bootstrap.js";
|
export * from "./bootstrap.js";
|
||||||
export * from "./database.js";
|
export * from "./database.js";
|
||||||
export * from "./evaluator.js";
|
export * from "./evaluator.js";
|
||||||
|
export * from "./initial-owner.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./ports.js";
|
export * from "./ports.js";
|
||||||
export * from "./store.js";
|
export * from "./store.js";
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||||
|
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import type { BootstrapIdentityCreator } from "../identity/bootstrap-identity.js";
|
||||||
|
import { BootstrapIdentityError } from "../identity/bootstrap-identity.js";
|
||||||
|
import type { IdentityDrizzleDatabase } from "../identity/database.js";
|
||||||
|
import { ekklesiaCoreAccount, ekklesiaCoreUser } from "../identity/schema.js";
|
||||||
|
import { bootstrapPlatformOwner, PlatformOwnerBootstrapError } from "./bootstrap.js";
|
||||||
|
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||||
|
import { ekklesiaCorePlatformOwnerBootstrap } from "./schema.js";
|
||||||
|
|
||||||
|
export type InitialPlatformOwnerErrorCode =
|
||||||
|
| "INITIAL_OWNER_ALREADY_COMPLETED"
|
||||||
|
| "INITIAL_OWNER_EMAIL_INVALID"
|
||||||
|
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
||||||
|
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
||||||
|
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
|
||||||
|
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
||||||
|
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
||||||
|
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
||||||
|
| "INITIAL_OWNER_SCHEMA_UNAVAILABLE";
|
||||||
|
|
||||||
|
const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, string>> = Object.freeze(
|
||||||
|
{
|
||||||
|
INITIAL_OWNER_ALREADY_COMPLETED:
|
||||||
|
"This installation already has a platform owner. Initial-owner bootstrap runs once and cannot run again.",
|
||||||
|
INITIAL_OWNER_EMAIL_INVALID: "The email address is not a valid address.",
|
||||||
|
INITIAL_OWNER_IDENTITY_NOT_CREATED:
|
||||||
|
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
||||||
|
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
||||||
|
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
||||||
|
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
|
||||||
|
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
|
||||||
|
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
||||||
|
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
||||||
|
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||||
|
INITIAL_OWNER_PASSWORD_TOO_SHORT: "The password is shorter than the identity system allows.",
|
||||||
|
INITIAL_OWNER_SCHEMA_UNAVAILABLE:
|
||||||
|
"The database is unreachable or has not been migrated. Apply migrations before creating the initial owner.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never carries the password, a hash, a connection string, SQL, a driver error, or a cause. The
|
||||||
|
* messages are written to be read by an operator at a terminal, so each one states what happened
|
||||||
|
* *and* what is still true about the installation — in particular whether bootstrap remains
|
||||||
|
* available, which is the only fact that determines what they should do next.
|
||||||
|
*/
|
||||||
|
export class InitialPlatformOwnerError extends Error {
|
||||||
|
readonly code: InitialPlatformOwnerErrorCode;
|
||||||
|
|
||||||
|
constructor(code: InitialPlatformOwnerErrorCode) {
|
||||||
|
super(initialOwnerMessages[code]);
|
||||||
|
this.name = "InitialPlatformOwnerError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CLI-level advisory key distinct from `bootstrap.ts`'s: taking the same one would deadlock this
|
||||||
|
// operation against the grant it performs. This serializes whole invocations; that one serializes
|
||||||
|
// the grant transaction itself, and both remain in force.
|
||||||
|
const initialOwnerLockNamespace = 1_500_000_003;
|
||||||
|
const initialOwnerLockKey = 1_500_000_004;
|
||||||
|
|
||||||
|
export interface CreateInitialPlatformOwnerDependencies {
|
||||||
|
readonly authorizationDb: AuthorizationDrizzleDatabase;
|
||||||
|
readonly identityDb: IdentityDrizzleDatabase;
|
||||||
|
readonly createIdentity: BootstrapIdentityCreator;
|
||||||
|
/** Injected so a partial failure — identity created, ownership not granted — can be exercised.
|
||||||
|
* Production composition leaves it unset and the real single-use bootstrap is used. */
|
||||||
|
readonly grantOwnership?: (userId: EkklesiaUserId) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInitialPlatformOwnerInput {
|
||||||
|
readonly email: string;
|
||||||
|
readonly password: string;
|
||||||
|
/** The display name the identity model requires. */
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitialPlatformOwnerResult {
|
||||||
|
readonly email: string;
|
||||||
|
readonly userId: EkklesiaUserId;
|
||||||
|
/** `created` when this call made the identity; `adopted` when one already existed for that
|
||||||
|
* address and only ownership was granted. Reported so an operator is never told something was
|
||||||
|
* created when it was not. */
|
||||||
|
readonly identity: "adopted" | "created";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExistingIdentity {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly hasCredential: boolean;
|
||||||
|
/** As the identity system actually stored it, which is not necessarily as the operator typed it. */
|
||||||
|
readonly storedEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an identity by email, case-insensitively.
|
||||||
|
*
|
||||||
|
* Deliberately not an exact match on the caller's spelling: Better Auth lowercases the address it
|
||||||
|
* stores (verified against the pinned release — `[email protected]` persists as
|
||||||
|
* `[email protected]`), so an exact comparison would fail to find the very account this operation
|
||||||
|
* had just created and would report it missing.
|
||||||
|
*/
|
||||||
|
async function findIdentityByEmail(
|
||||||
|
db: IdentityDrizzleDatabase,
|
||||||
|
email: string,
|
||||||
|
): Promise<ExistingIdentity | undefined> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ email: ekklesiaCoreUser.email, id: ekklesiaCoreUser.id })
|
||||||
|
.from(ekklesiaCoreUser)
|
||||||
|
.where(sql`lower(${ekklesiaCoreUser.email}) = ${email.trim().toLowerCase()}`)
|
||||||
|
.limit(1);
|
||||||
|
const found = rows[0];
|
||||||
|
if (!found) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// A row in `user` alone is not an account anyone can sign in as — the credential lives in
|
||||||
|
// `account`. The distinction matters: granting ownership to a passwordless row would consume the
|
||||||
|
// one-time bootstrap on an identity nobody can authenticate as, leaving the installation with an
|
||||||
|
// owner that cannot log in and no second chance.
|
||||||
|
const credentials = await db
|
||||||
|
.select({ id: ekklesiaCoreAccount.id })
|
||||||
|
.from(ekklesiaCoreAccount)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreAccount.userId, found.id),
|
||||||
|
eq(ekklesiaCoreAccount.providerId, "credential"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks one identity's address verified.
|
||||||
|
*
|
||||||
|
* A direct column update rather than an identity-system call, because the pinned release exposes no
|
||||||
|
* supported way to mark an address verified other than consuming a token it emailed — which is the
|
||||||
|
* one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the
|
||||||
|
* exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from
|
||||||
|
* one that verified normally; nothing here invents a parallel notion of verification.
|
||||||
|
*/
|
||||||
|
async function markIdentityEmailVerified(
|
||||||
|
db: IdentityDrizzleDatabase,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(ekklesiaCoreUser)
|
||||||
|
.set({ emailVerified: true })
|
||||||
|
.where(eq(ekklesiaCoreUser.id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
|
||||||
|
if (error instanceof BootstrapIdentityError) {
|
||||||
|
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
||||||
|
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_SHORT");
|
||||||
|
}
|
||||||
|
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG") {
|
||||||
|
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_LONG");
|
||||||
|
}
|
||||||
|
if (error.code === "BOOTSTRAP_IDENTITY_EMAIL_INVALID") {
|
||||||
|
return new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the first platform owner of a fresh installation: one identity, then the single-use
|
||||||
|
* ownership grant.
|
||||||
|
*
|
||||||
|
* ## Why the identity is verified rather than trusted
|
||||||
|
*
|
||||||
|
* Better Auth's sign-up returns a **fabricated success** when the address is already registered —
|
||||||
|
* a user object carrying an id that was never persisted, with the database left unchanged (verified
|
||||||
|
* against the pinned release). On a public endpoint that is correct: it refuses to tell an
|
||||||
|
* anonymous caller which addresses exist. Here it is a trap, because a caller that believed the
|
||||||
|
* response would report an owner it never created and then grant ownership to an id that does not
|
||||||
|
* exist. So the identity is always read back from the database, and that read — not the sign-up
|
||||||
|
* response — is what the grant uses.
|
||||||
|
*
|
||||||
|
* ## Why an existing identity is adopted rather than refused or overwritten
|
||||||
|
*
|
||||||
|
* If the address already has a usable account and bootstrap is still open, ownership is granted to
|
||||||
|
* it and **its password is left completely alone**. This is precisely the recovery path for the one
|
||||||
|
* partial failure this operation can have: sign-up commits through Better Auth's own connection and
|
||||||
|
* the grant runs in a separate transaction, so a crash between them leaves an identity with no
|
||||||
|
* ownership. Refusing would strand a fresh installation with no way forward but manual SQL; a
|
||||||
|
* password reset would be a silent takeover of an account the operator may not own. Adoption
|
||||||
|
* changes no credential, and it is reported as `adopted` so nobody is told an account was created
|
||||||
|
* when it was not.
|
||||||
|
*
|
||||||
|
* That is safe here specifically because production has **no** other way to create an identity —
|
||||||
|
* public signup cannot be enabled at all — so an account present on a fresh installation was put
|
||||||
|
* there by someone who already had database access.
|
||||||
|
*
|
||||||
|
* ## Ordering
|
||||||
|
*
|
||||||
|
* The bootstrap sentinel is checked before an identity is created, so a closed bootstrap never
|
||||||
|
* leaves a stray account behind. That check is an early exit, not the authority: `bootstrapPlatformOwner`
|
||||||
|
* re-checks it inside its own locked transaction, which is what actually makes the grant single-use.
|
||||||
|
*/
|
||||||
|
export async function createInitialPlatformOwner(
|
||||||
|
dependencies: CreateInitialPlatformOwnerDependencies,
|
||||||
|
input: CreateInitialPlatformOwnerInput,
|
||||||
|
): Promise<InitialPlatformOwnerResult> {
|
||||||
|
const email = input.email.trim();
|
||||||
|
if (email.length === 0) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serializes entire invocations against each other. Without it two operators racing on a fresh
|
||||||
|
// installation would both pass the sentinel check and both create an identity, and only then
|
||||||
|
// would one lose the grant — leaving a stray account that can sign in and owns nothing. The grant
|
||||||
|
// alone was already safe; this makes the whole command safe.
|
||||||
|
return dependencies.authorizationDb.transaction(async (lockTx) => {
|
||||||
|
await lockTx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(${initialOwnerLockNamespace}, ${initialOwnerLockKey})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let alreadyConsumed: boolean;
|
||||||
|
try {
|
||||||
|
const consumed = await lockTx
|
||||||
|
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||||
|
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||||
|
.limit(1);
|
||||||
|
alreadyConsumed = consumed.length > 0;
|
||||||
|
} catch {
|
||||||
|
// An unreachable database and a database without the authorization tables are the same
|
||||||
|
// situation to an operator running this on a fresh server: the deployment step before this
|
||||||
|
// one has not happened. Never migrated implicitly — see ADR 0035.
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
if (alreadyConsumed) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: ExistingIdentity | undefined;
|
||||||
|
try {
|
||||||
|
existing = await findIdentityByEmail(dependencies.identityDb, email);
|
||||||
|
} catch {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
if (existing && !existing.hasCredential) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_UNUSABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captured before the read-back below, which cannot distinguish the two: an identity this call
|
||||||
|
// just created and one it adopted both come back with a credential.
|
||||||
|
const identity: "adopted" | "created" = existing ? "adopted" : "created";
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
try {
|
||||||
|
await dependencies.createIdentity({
|
||||||
|
email,
|
||||||
|
name: input.name,
|
||||||
|
password: input.password,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw translateIdentityCreationError(error);
|
||||||
|
}
|
||||||
|
existing = await findIdentityByEmail(dependencies.identityDb, email).catch(() => undefined);
|
||||||
|
if (!existing) {
|
||||||
|
// Reached when sign-up reported success without persisting anything.
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUserId = safeParseEkklesiaUserId(existing.userId);
|
||||||
|
if (!parsedUserId.success) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trusted-bootstrap verification exception (ADR 0038).
|
||||||
|
*
|
||||||
|
* Every other human account must verify its address by email before it may hold a session.
|
||||||
|
* This one cannot: it is created on a freshly provisioned server by an operator who already
|
||||||
|
* holds the database credentials, at a moment when SMTP may not be configured, may not be
|
||||||
|
* reachable, or may not yet have a DNS record to send from. Requiring an email round trip here
|
||||||
|
* would mean a working installation whose administrator can never get in — the platform's
|
||||||
|
* outbound mail would have to be configured and correct before anyone could sign in to
|
||||||
|
* configure anything.
|
||||||
|
*
|
||||||
|
* The exception is narrow and its authority is the same authority that already makes this
|
||||||
|
* command safe: possession of the database, not possession of the mailbox. It marks exactly one
|
||||||
|
* address, exactly once per installation, and it is deliberately performed **before** ownership
|
||||||
|
* is granted — if it fails, the sentinel and the grant roll back together and re-running the
|
||||||
|
* command adopts the identity and finishes, rather than leaving an owner who cannot sign in.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data);
|
||||||
|
} catch {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const grant =
|
||||||
|
dependencies.grantOwnership ??
|
||||||
|
((userId: EkklesiaUserId) =>
|
||||||
|
bootstrapPlatformOwner(dependencies.authorizationDb, { userId }));
|
||||||
|
await grant(parsedUserId.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof PlatformOwnerBootstrapError &&
|
||||||
|
error.code === "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||||
|
) {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||||
|
}
|
||||||
|
// The identity is real and remains usable; the sentinel rolled back with the grant, so
|
||||||
|
// re-running this command with the same address adopts it and finishes.
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_OWNERSHIP_GRANT_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored address, never the operator's spelling: the identity system lowercases what it
|
||||||
|
// persists, and telling someone to sign in as `[email protected]` when the account is
|
||||||
|
// `[email protected]` invites them to conclude the command failed.
|
||||||
|
return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -83,6 +83,92 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
|||||||
"core.authorization.grants.manage",
|
"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
|
* 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
|
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||||
@@ -96,6 +182,31 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
|||||||
description: "Create, list, and revoke another user's explicit authorization grants.",
|
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||||
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "authenticated" as const,
|
||||||
|
description: "List the installed module UIs a browser host may mount.",
|
||||||
|
id: CORE_MODULES_UI_READ,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
||||||
|
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Read and change which installed modules are enabled.",
|
||||||
|
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Read and change an installed module's own configuration values.",
|
||||||
|
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "restricted" as const,
|
||||||
|
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||||
|
id: CORE_DEVICES_MANAGE,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
import type {
|
||||||
|
AuthorizationScope,
|
||||||
|
EkklesiaDeviceId,
|
||||||
|
EkklesiaUserId,
|
||||||
|
PermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
export interface ActiveGrantQuery {
|
export interface ActiveGrantQuery {
|
||||||
readonly userId: EkklesiaUserId;
|
readonly userId: EkklesiaUserId;
|
||||||
@@ -26,3 +31,17 @@ export interface AuthorizationGrantPort {
|
|||||||
export interface AuthorizationPlatformOwnerPort {
|
export interface AuthorizationPlatformOwnerPort {
|
||||||
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
|
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The evaluator's only read dependency for **device** authority.
|
||||||
|
*
|
||||||
|
* Separate from `AuthorizationGrantPort` because the two answer questions about different kinds of
|
||||||
|
* subject, not because devices are a special case in the decision: `authorize` consults exactly one
|
||||||
|
* of them per call, by principal kind, and neither can be reached without going through it.
|
||||||
|
*
|
||||||
|
* There is deliberately no device counterpart to `AuthorizationPlatformOwnerPort`. A device is
|
||||||
|
* never a platform owner, so there is no port that could accidentally say it was.
|
||||||
|
*/
|
||||||
|
export interface AuthorizationDevicePort {
|
||||||
|
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
createAuthorizationDrizzleDatabase,
|
||||||
|
createInitialPlatformOwner,
|
||||||
|
InitialPlatformOwnerError,
|
||||||
|
} from "../authorization/index.js";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
} from "../identity/index.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the first platform owner of a fresh installation — one identity, then the single-use
|
||||||
|
* ownership grant — so a freshly deployed Ekklesia has a supported way to get its first
|
||||||
|
* administrator (ADR 0035).
|
||||||
|
*
|
||||||
|
* This exists because the login page has nothing to log in to on a new installation. Public signup
|
||||||
|
* is disabled in every environment and production cannot enable it at all, which is correct and is
|
||||||
|
* not changed here; the consequence was simply that no supported path created the first account.
|
||||||
|
* This is that path, and it is deliberately the same shape as `pnpm authorization:bootstrap-owner`
|
||||||
|
* and `pnpm modules:enable`: explicit, operator-invoked, never HTTP-exposed, never inferred, and
|
||||||
|
* reachable only by someone who already holds the database credentials.
|
||||||
|
*
|
||||||
|
* It creates no HTTP route, starts no server, and applies no migration.
|
||||||
|
*/
|
||||||
|
class InitialOwnerCliError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "InitialOwnerCliError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage =
|
||||||
|
"Usage: pnpm identity:create-initial-owner -- --email <address> [--name <display name>]\n" +
|
||||||
|
"The password is never taken from the command line. It is prompted for when a terminal is\n" +
|
||||||
|
"attached, and otherwise read from standard input:\n" +
|
||||||
|
" printf '%s' \"$PASSWORD\" | pnpm identity:create-initial-owner -- --email [email protected]";
|
||||||
|
|
||||||
|
interface ParsedArgs {
|
||||||
|
readonly email: string;
|
||||||
|
readonly name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts` and
|
||||||
|
* `set-module-enabled.ts`. There is deliberately no `--password`: see `readPassword`. */
|
||||||
|
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||||
|
let email: string | undefined;
|
||||||
|
let name: string | undefined;
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const flag = argv[index];
|
||||||
|
if (flag === "--email") {
|
||||||
|
email = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (flag === "--name") {
|
||||||
|
name = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (flag === "--password" || flag?.startsWith("--password=") === true) {
|
||||||
|
// Refused explicitly rather than ignored, so an operator who tries it learns why instead of
|
||||||
|
// wondering why their password was not used.
|
||||||
|
throw new InitialOwnerCliError(
|
||||||
|
"--password is not supported: a command-line password is visible in shell history and in the process list.\n" +
|
||||||
|
usage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email === undefined || email.trim().length === 0) {
|
||||||
|
throw new InitialOwnerCliError(usage);
|
||||||
|
}
|
||||||
|
return name === undefined ? { email } : { email, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads one line without echoing it. No dependency is added for this: the command needs exactly
|
||||||
|
* one hidden field, and Node's own raw-mode stdin is enough. */
|
||||||
|
function promptHidden(prompt: string): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const input = process.stdin;
|
||||||
|
process.stdout.write(prompt);
|
||||||
|
input.setRawMode(true);
|
||||||
|
input.resume();
|
||||||
|
input.setEncoding("utf8");
|
||||||
|
let value = "";
|
||||||
|
const finish = (settle: () => void): void => {
|
||||||
|
input.removeListener("data", onData);
|
||||||
|
input.setRawMode(false);
|
||||||
|
input.pause();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
settle();
|
||||||
|
};
|
||||||
|
const onData = (chunk: string): void => {
|
||||||
|
for (const character of chunk) {
|
||||||
|
if (character === "\r" || character === "\n" || character === "\u0004") {
|
||||||
|
finish(() => resolve(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (character === "\u0003") {
|
||||||
|
finish(() => reject(new InitialOwnerCliError("Cancelled. No changes were made.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Backspace and delete, so a mistyped character can be corrected in a field nobody can see.
|
||||||
|
if (character === "\u007f" || character === "\b") {
|
||||||
|
value = value.slice(0, -1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value += character;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.on("data", onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads standard input to end, for automated deployment. */
|
||||||
|
function readStdin(): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (chunk: string) => {
|
||||||
|
buffer += chunk;
|
||||||
|
});
|
||||||
|
process.stdin.on("end", () => resolve(buffer));
|
||||||
|
process.stdin.on("error", () =>
|
||||||
|
reject(new InitialOwnerCliError("Standard input could not be read.")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains the password without it ever appearing in `argv`, an environment variable, or the
|
||||||
|
* terminal.
|
||||||
|
*
|
||||||
|
* A command-line password is visible in shell history and to every other process on the machine via
|
||||||
|
* the process list, and an environment variable tends to become permanent — a bootstrap password
|
||||||
|
* belongs to one invocation, not to the deployment's configuration, so this command deliberately
|
||||||
|
* reads no environment variable for it and none is added to `.env.example`.
|
||||||
|
*
|
||||||
|
* Interactive use asks twice and compares, because the field is invisible and a typo would create
|
||||||
|
* an owner nobody can sign in as, on an installation whose one bootstrap has now been consumed.
|
||||||
|
*/
|
||||||
|
async function readPassword(): Promise<string> {
|
||||||
|
if (process.stdin.isTTY === true) {
|
||||||
|
const password = await promptHidden("Password: ");
|
||||||
|
const confirmation = await promptHidden("Confirm password: ");
|
||||||
|
if (password !== confirmation) {
|
||||||
|
throw new InitialOwnerCliError("The passwords did not match. No changes were made.");
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
// A single trailing newline is stripped so `printf '%s\n'`, a here-string, and an editor-produced
|
||||||
|
// file all behave the same; nothing else is trimmed, because leading and trailing spaces are
|
||||||
|
// legitimate password characters.
|
||||||
|
const piped = await readStdin();
|
||||||
|
return piped.replace(/\r?\n$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const { email, name } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
// Read only when this command actually executes — never merely by importing this module.
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||||
|
throw new InitialOwnerCliError("DATABASE_URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = await readPassword();
|
||||||
|
if (password.length === 0) {
|
||||||
|
throw new InitialOwnerCliError("A password is required. No changes were made.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asserted before any connection is constructed, so an unsafe production transport never opens a
|
||||||
|
// client at all — identical to every other Core executable.
|
||||||
|
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||||
|
const databaseOptions = {
|
||||||
|
connectionTimeoutMs: 10_000,
|
||||||
|
operationTimeoutMs: 30_000,
|
||||||
|
transport,
|
||||||
|
};
|
||||||
|
const authorization = createAuthorizationDrizzleDatabase(databaseUrl, databaseOptions);
|
||||||
|
const identity = createIdentityDrizzleDatabase(databaseUrl, databaseOptions);
|
||||||
|
try {
|
||||||
|
const result = await createInitialPlatformOwner(
|
||||||
|
{
|
||||||
|
authorizationDb: authorization.db,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
identityDb: identity.db,
|
||||||
|
},
|
||||||
|
{ email, name: name ?? email, password },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Never the password, its length, a hash, a session token, a user id, or the database URL.
|
||||||
|
console.log(
|
||||||
|
result.identity === "created"
|
||||||
|
? "Initial platform owner created successfully."
|
||||||
|
: "An account already existed for that address; platform ownership was granted to it and its password was left unchanged.",
|
||||||
|
);
|
||||||
|
console.log(`Email: ${result.email}`);
|
||||||
|
console.log("You can now sign in to the Ekklesia dashboard.");
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
authorization.close().catch(() => undefined),
|
||||||
|
identity.close().catch(() => undefined),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportInitialOwnerCliError(error: unknown): void {
|
||||||
|
if (error instanceof InitialOwnerCliError) {
|
||||||
|
console.error(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof InitialPlatformOwnerError) {
|
||||||
|
console.error(`[${error.code}] ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deliberately generic: the caught value may carry SQL, a connection string, a driver detail, or
|
||||||
|
// the password itself if it were ever interpolated into a message upstream.
|
||||||
|
console.error("Initial platform owner creation failed unexpectedly.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectExecution(): boolean {
|
||||||
|
const entrypoint = process.argv[1];
|
||||||
|
return (
|
||||||
|
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectExecution()) {
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
reportInitialOwnerCliError(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,35 +4,28 @@ import {
|
|||||||
type DatabaseTransportPolicy,
|
type DatabaseTransportPolicy,
|
||||||
createDatabaseTransportPolicy,
|
createDatabaseTransportPolicy,
|
||||||
} from "../database/transport.js";
|
} from "../database/transport.js";
|
||||||
|
import { loadHostConfiguration } from "../host/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one place every Core-owned executable reads its database transport policy from the process
|
* The database transport policy for the small Core-owned executables — `pnpm migrations:plan`,
|
||||||
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and
|
* `pnpm migrations:apply`, `pnpm modules:enable`, and the owner-bootstrap commands — resolved from
|
||||||
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`.
|
* the process environment.
|
||||||
*
|
*
|
||||||
* Environment reading lives here, in the executable layer, deliberately: the reusable Core
|
* The mapping itself belongs to `loadHostConfiguration`, which `startApi` and `pnpm doctor` also
|
||||||
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`.
|
* call, so `NODE_ENV` and the two TLS variables have exactly one interpretation across every entry
|
||||||
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string
|
* point. Environment *reading* still happens here, in the executable layer: the reusable Core
|
||||||
* `"development"` relaxes anything, so an unset or misspelled value validates as production.
|
* database and migration constructors keep taking explicit typed inputs and never touch
|
||||||
|
* `process.env`.
|
||||||
*/
|
*/
|
||||||
export function readDatabaseTransportFromEnvironment(): {
|
export function readDatabaseTransportFromEnvironment(): {
|
||||||
readonly environment: DatabaseTransportEnvironment;
|
readonly environment: DatabaseTransportEnvironment;
|
||||||
readonly transport: DatabaseTransportPolicy;
|
readonly transport: DatabaseTransportPolicy;
|
||||||
} {
|
} {
|
||||||
const environment: DatabaseTransportEnvironment =
|
const host = loadHostConfiguration(process.env);
|
||||||
process.env.NODE_ENV === "development" ? "development" : "production";
|
const transport = createDatabaseTransportPolicy(host.databaseTransport, {
|
||||||
const transport = createDatabaseTransportPolicy(
|
environment: host.environment,
|
||||||
{
|
});
|
||||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
return { environment: host.environment, transport };
|
||||||
? {}
|
|
||||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
|
||||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
|
||||||
? {}
|
|
||||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
|
||||||
},
|
|
||||||
{ environment },
|
|
||||||
);
|
|
||||||
return { environment, transport };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+51
-52
@@ -2,11 +2,12 @@ import { readFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
diagnoseEkklesia,
|
|
||||||
type DoctorOptions,
|
type DoctorOptions,
|
||||||
|
diagnoseEkklesia,
|
||||||
doctorExitCode,
|
doctorExitCode,
|
||||||
formatDoctorReport,
|
formatDoctorReport,
|
||||||
} from "../diagnostics/index.js";
|
} from "../diagnostics/index.js";
|
||||||
|
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
||||||
|
|
||||||
interface ModuleSdkPackageDocument {
|
interface ModuleSdkPackageDocument {
|
||||||
readonly version?: unknown;
|
readonly version?: unknown;
|
||||||
@@ -18,6 +19,18 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything Doctor needs that comes from the repository itself rather than the deployment
|
||||||
|
* environment, resolved by the caller so the environment mapping below stays pure and testable.
|
||||||
|
*/
|
||||||
|
export interface DoctorCommandMetadata {
|
||||||
|
readonly moduleSdkVersion: string;
|
||||||
|
readonly modulesDirectory: string;
|
||||||
|
readonly platformVersion: string;
|
||||||
|
readonly runtimeRange: string;
|
||||||
|
readonly runtimeVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson(pathname: string): Promise<unknown> {
|
async function readJson(pathname: string): Promise<unknown> {
|
||||||
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
||||||
}
|
}
|
||||||
@@ -36,40 +49,40 @@ function requiredString(value: unknown): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
|
/**
|
||||||
const secret = process.env.EKKLESIA_IDENTITY_SECRET;
|
* Turns one deployment environment into Doctor's inputs.
|
||||||
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
|
*
|
||||||
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED;
|
* Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
|
||||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
* with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
|
||||||
const trustedOrigins = trustedOriginsValue
|
* deployment is configured to use. Doctor previously read its own subset here and silently omitted
|
||||||
? trustedOriginsValue
|
* object storage and module secrets entirely, which made it report a correctly configured
|
||||||
.split(",")
|
* installation as having neither.
|
||||||
.map((origin) => origin.trim())
|
*
|
||||||
.filter((origin) => origin.length > 0)
|
* Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
|
||||||
: undefined;
|
* malformed value appears in the report — which is the whole reason an operator runs this command —
|
||||||
|
* instead of throwing before a report exists.
|
||||||
|
*/
|
||||||
|
export function createDoctorOptions(
|
||||||
|
environment: HostEnvironment,
|
||||||
|
metadata: DoctorCommandMetadata,
|
||||||
|
): DoctorOptions {
|
||||||
|
const host = loadHostConfiguration(environment);
|
||||||
return {
|
return {
|
||||||
...(baseUrl ? { baseUrl } : {}),
|
clientAddress: host.clientAddress,
|
||||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
databaseEnvironment: host.environment,
|
||||||
...(publicSignupEnabled ? { publicSignupEnabled } : {}),
|
databaseTransport: host.databaseTransport,
|
||||||
...(secret ? { secret } : {}),
|
databaseUrl: host.databaseUrl,
|
||||||
...(trustedOrigins ? { trustedOrigins } : {}),
|
identityConfiguration: { ...host.identity, environment: host.environment },
|
||||||
};
|
mailConfiguration: host.mail,
|
||||||
}
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: metadata.moduleSdkVersion,
|
||||||
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] {
|
modulesDirectory: metadata.modulesDirectory,
|
||||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
platformVersion: metadata.platformVersion,
|
||||||
if (mode === undefined) {
|
},
|
||||||
return undefined;
|
moduleSecrets: host.moduleSecrets,
|
||||||
}
|
runtimeRange: metadata.runtimeRange,
|
||||||
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
|
runtimeVersion: metadata.runtimeVersion,
|
||||||
return {
|
storageConfiguration: host.storage,
|
||||||
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
|
||||||
mode,
|
|
||||||
trustedProxies: trustedProxiesValue
|
|
||||||
?.split(",")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,29 +94,15 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
|||||||
const rootPackage = rootPackageDocument(rootValue);
|
const rootPackage = rootPackageDocument(rootValue);
|
||||||
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
||||||
|
|
||||||
const databaseEnvironment =
|
const report = await diagnoseEkklesia(
|
||||||
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const);
|
createDoctorOptions(process.env, {
|
||||||
const report = await diagnoseEkklesia({
|
|
||||||
clientAddress: readClientAddressFromEnvironment(),
|
|
||||||
databaseEnvironment,
|
|
||||||
databaseTransport: {
|
|
||||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
|
||||||
? {}
|
|
||||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
|
||||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
|
||||||
? {}
|
|
||||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
|
||||||
},
|
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
|
||||||
identityConfiguration: readIdentityConfigurationFromEnvironment(),
|
|
||||||
moduleDiscovery: {
|
|
||||||
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||||
platformVersion: requiredString(rootPackage.version),
|
platformVersion: requiredString(rootPackage.version),
|
||||||
},
|
|
||||||
runtimeRange: requiredString(rootPackage.engines?.node),
|
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||||
runtimeVersion: process.versions.node,
|
runtimeVersion: process.versions.node,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
console.log(formatDoctorReport(report).join("\n"));
|
console.log(formatDoctorReport(report).join("\n"));
|
||||||
return doctorExitCode(report);
|
return doctorExitCode(report);
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
createModuleLifecycleDatabase,
|
||||||
|
createModuleLifecycleStore,
|
||||||
|
discoverModules,
|
||||||
|
isDependencyBlockedDetail,
|
||||||
|
isStorageBlockedDetail,
|
||||||
|
ModuleDiscoveryError,
|
||||||
|
ModuleLifecycleStoreError,
|
||||||
|
resolveModuleEligibility,
|
||||||
|
} from "../modules/index.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables or disables one installed module, out of band from the running API.
|
||||||
|
*
|
||||||
|
* This exists because module startup is deliberately all-or-nothing (ADR 0015, ADR 0020, preserved
|
||||||
|
* by ADR 0028): a module whose `start` hook throws takes API startup down with it, and the HTTP
|
||||||
|
* lifecycle API that could otherwise turn it off is served by that same process. Without an
|
||||||
|
* out-of-band path, the only recovery from one bad module would be editing the database by hand.
|
||||||
|
* This command is that path — the same shape as `pnpm authorization:bootstrap-owner`: explicit,
|
||||||
|
* operator-invoked, never HTTP-exposed, and never inferred.
|
||||||
|
*
|
||||||
|
* It writes configuration and nothing else. It starts nothing, stops nothing, imports no module
|
||||||
|
* server entrypoint, runs no lifecycle hook, and applies no migration. The change takes effect on
|
||||||
|
* the next API start.
|
||||||
|
*/
|
||||||
|
class ModuleLifecycleCliError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ModuleLifecycleCliError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage =
|
||||||
|
"Usage: pnpm modules:enable -- --module <module-id> | pnpm modules:disable -- --module <module-id>";
|
||||||
|
|
||||||
|
interface ParsedArgs {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly moduleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts`: this command
|
||||||
|
* takes one named flag plus one mode flag, so a general-purpose argument dependency is not
|
||||||
|
* warranted. `--enable` and `--disable` are mutually exclusive and one is required, so the intent
|
||||||
|
* is always stated rather than defaulted. */
|
||||||
|
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||||
|
let moduleId: string | undefined;
|
||||||
|
let enabled: boolean | undefined;
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const flag = argv[index];
|
||||||
|
if (flag === "--module") {
|
||||||
|
moduleId = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (flag === "--enable") {
|
||||||
|
if (enabled === false) {
|
||||||
|
throw new ModuleLifecycleCliError(usage);
|
||||||
|
}
|
||||||
|
enabled = true;
|
||||||
|
} else if (flag === "--disable") {
|
||||||
|
if (enabled === true) {
|
||||||
|
throw new ModuleLifecycleCliError(usage);
|
||||||
|
}
|
||||||
|
enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moduleId === undefined || enabled === undefined) {
|
||||||
|
throw new ModuleLifecycleCliError(usage);
|
||||||
|
}
|
||||||
|
return { enabled, moduleId };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageVersion {
|
||||||
|
readonly version?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPackageVersion(packagePath: string): Promise<string> {
|
||||||
|
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
|
||||||
|
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
|
||||||
|
throw new ModuleLifecycleCliError("The repository package metadata could not be read.");
|
||||||
|
}
|
||||||
|
return packageData.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const { enabled, moduleId } = parseArgs(process.argv.slice(2));
|
||||||
|
const repositoryRoot = process.cwd();
|
||||||
|
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||||
|
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||||
|
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The same rule the HTTP API enforces: a module this deployment cannot currently see may not be
|
||||||
|
// configured. Manifest-only, exactly like `pnpm modules:validate` — no module server entrypoint
|
||||||
|
// is imported and no lifecycle hook runs, so a module that crashes during `start` is still fully
|
||||||
|
// configurable here, which is the case this command exists for.
|
||||||
|
const discovered = await discoverModules({
|
||||||
|
moduleSdkVersion,
|
||||||
|
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||||
|
platformVersion,
|
||||||
|
});
|
||||||
|
if (!discovered.some((module) => module.manifest.id === moduleId)) {
|
||||||
|
throw new ModuleLifecycleCliError(
|
||||||
|
`No installed module declares the id "${moduleId}". No changes were made.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read only when this command actually executes — never merely by importing this module.
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||||
|
throw new ModuleLifecycleCliError("DATABASE_URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asserted before the connection is constructed, so an unsafe production transport never opens a
|
||||||
|
// client at all.
|
||||||
|
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||||
|
const { close, db } = createModuleLifecycleDatabase(databaseUrl, {
|
||||||
|
connectionTimeoutMs: 10_000,
|
||||||
|
operationTimeoutMs: 10_000,
|
||||||
|
transport,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const store = createModuleLifecycleStore(db);
|
||||||
|
await store.setEnabled({ enabled, moduleId });
|
||||||
|
const resolution = resolveModuleEligibility(discovered, await store.readConfiguration());
|
||||||
|
console.log(
|
||||||
|
`Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`,
|
||||||
|
);
|
||||||
|
for (const entry of resolution.entries) {
|
||||||
|
const blocked = entry.blocked;
|
||||||
|
if (!blocked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Names the dependency, the configuration key, or the logical storage bucket — never a
|
||||||
|
// configuration value, an endpoint, or a credential.
|
||||||
|
const cause = isDependencyBlockedDetail(blocked)
|
||||||
|
? blocked.dependency.moduleId
|
||||||
|
: isStorageBlockedDetail(blocked)
|
||||||
|
? blocked.storageBucketId
|
||||||
|
: blocked.configurationKey;
|
||||||
|
console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportCliError(error: unknown): void {
|
||||||
|
if (error instanceof ModuleLifecycleCliError) {
|
||||||
|
console.error(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof ModuleLifecycleStoreError) {
|
||||||
|
console.error(`[${error.code}] ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof ModuleDiscoveryError) {
|
||||||
|
console.error(error.message);
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||||
|
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("The module enablement change failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectExecution(): boolean {
|
||||||
|
const entrypoint = process.argv[1];
|
||||||
|
return (
|
||||||
|
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectExecution()) {
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
reportCliError(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { PermissionRegistryError } from "../authorization/index.js";
|
import { PermissionRegistryError } from "../authorization/index.js";
|
||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
ModulePermissionCompositionError,
|
ModulePermissionCompositionError,
|
||||||
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built from the same registry the API publishes from, so this command can never disagree with
|
||||||
|
// what a running platform would actually serve. A contribution whose target capability nobody
|
||||||
|
// provides — or whose provider moved outside its declared range — is otherwise silent: the
|
||||||
|
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
|
||||||
|
const publication = createModuleUiPublicationRegistry(modules);
|
||||||
|
const compatibleContributions = new Set(
|
||||||
|
publication
|
||||||
|
.listContributions()
|
||||||
|
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
||||||
);
|
);
|
||||||
for (const discoveredModule of modules) {
|
for (const discoveredModule of modules) {
|
||||||
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
||||||
|
for (const contribution of discoveredModule.contributions) {
|
||||||
|
const resolved = compatibleContributions.has(
|
||||||
|
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
|
||||||
|
resolved ? "compatible provider installed" : "no compatible provider installed"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./navigation.js";
|
||||||
|
export * from "./schema.js";
|
||||||
|
export * from "./store.js";
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { DashboardConfiguration } from "./store.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module as the Dashboard needs to present it: its identity, its administrator-visible name,
|
||||||
|
* and nothing about where it lives on disk.
|
||||||
|
*
|
||||||
|
* `name` is the module's own `manifest.name` — its product identity, which every manifest has
|
||||||
|
* already required since version 1. The Dashboard renders it rather than the module id, and a module
|
||||||
|
* declaring it is *not* the module declaring its navigation: a name answers "what is this product
|
||||||
|
* called", while a group answers "where does this church want it", and only the second is
|
||||||
|
* configuration an administrator owns.
|
||||||
|
*/
|
||||||
|
export interface DashboardNavigationModule {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationGroupView<Module extends DashboardNavigationModule> {
|
||||||
|
/** The stable key, unchanged by a rename. Callers use it to address the group, never to display. */
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly order: number;
|
||||||
|
readonly modules: readonly Module[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationView<Module extends DashboardNavigationModule> {
|
||||||
|
readonly groups: readonly DashboardNavigationGroupView<Module>[];
|
||||||
|
/**
|
||||||
|
* Modules that belong to no group: those an administrator deliberately placed outside every group,
|
||||||
|
* and those never configured at all — a newly installed module, most importantly. Both render
|
||||||
|
* here, which is what guarantees a new module is visible the moment it is discovered, with no
|
||||||
|
* initialization write, no manual database edit, and no migration.
|
||||||
|
*/
|
||||||
|
readonly ungrouped: readonly Module[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placed modules first, in the administrator's order; never-configured modules after them, by id.
|
||||||
|
* A newcomer therefore appears without interrupting an ordering someone chose, and the module id is
|
||||||
|
* always the final tie-break, so two modules sharing a `sortOrder` still order identically on every
|
||||||
|
* request and in every process. */
|
||||||
|
interface SortKey {
|
||||||
|
readonly configured: 0 | 1;
|
||||||
|
readonly order: number;
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSortKeys(left: SortKey, right: SortKey): number {
|
||||||
|
if (left.configured !== right.configured) {
|
||||||
|
return left.configured - right.configured;
|
||||||
|
}
|
||||||
|
if (left.order !== right.order) {
|
||||||
|
return left.order - right.order;
|
||||||
|
}
|
||||||
|
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves stored Dashboard configuration against the modules that are actually mountable right now.
|
||||||
|
*
|
||||||
|
* Pure and synchronous: no database, no filesystem, no clock, no module code. It is given the stored
|
||||||
|
* arrangement and the current module set and returns what to render, so every rule below is
|
||||||
|
* directly testable without PostgreSQL.
|
||||||
|
*
|
||||||
|
* ## The rules, exactly
|
||||||
|
*
|
||||||
|
* - **A module with no placement is ungrouped and visible.** Nothing is written to make that true.
|
||||||
|
* Installing a module is enough to see it; an administrator moves it afterwards if they want to.
|
||||||
|
* - **A stored placement for a module that is not installed renders nothing, and is retained.** The
|
||||||
|
* store keeps the row, so uninstalling and reinstalling a module restores its position rather than
|
||||||
|
* silently resetting it. A dead navigation entry never appears.
|
||||||
|
* - **A placement naming a group that no longer exists cannot occur** — the schema's
|
||||||
|
* `on delete set null` makes the module ungrouped when its group is deleted — but if one somehow
|
||||||
|
* did, it is treated as ungrouped rather than dropped, so no module can be made invisible by a
|
||||||
|
* configuration inconsistency.
|
||||||
|
* - **Empty groups are kept.** A group an administrator created and has not filled yet, or whose
|
||||||
|
* only module is temporarily uninstalled, still renders; hiding it would look like the group had
|
||||||
|
* been deleted.
|
||||||
|
* - **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)`. Colliding
|
||||||
|
* order values never produce a nondeterministic result.
|
||||||
|
*
|
||||||
|
* ## What this function structurally cannot do
|
||||||
|
*
|
||||||
|
* It never reads a manifest, a dependency, a capability, an entrypoint, or a permission, and it
|
||||||
|
* returns no edge between two modules. Two modules sharing a group are exactly as related as two
|
||||||
|
* modules that do not: grouping is a rendering decision computed here, downstream of everything that
|
||||||
|
* decides what a module *is* (ADR 0027). Nothing upstream — discovery, dependency ordering, runtime
|
||||||
|
* startup, authorization — ever calls this.
|
||||||
|
*/
|
||||||
|
export function resolveDashboardNavigation<Module extends DashboardNavigationModule>(
|
||||||
|
configuration: DashboardConfiguration,
|
||||||
|
mountableModules: readonly Module[],
|
||||||
|
): DashboardNavigationView<Module> {
|
||||||
|
const placementsByModuleId = new Map(
|
||||||
|
configuration.placements.map((placement) => [placement.moduleId, placement] as const),
|
||||||
|
);
|
||||||
|
const groupIds = new Set(configuration.groups.map((group) => group.id));
|
||||||
|
|
||||||
|
const modulesByGroupId = new Map<string, Array<{ key: SortKey; module: Module }>>();
|
||||||
|
const ungrouped: Array<{ key: SortKey; module: Module }> = [];
|
||||||
|
|
||||||
|
for (const module of mountableModules) {
|
||||||
|
const placement = placementsByModuleId.get(module.id);
|
||||||
|
const key: SortKey = {
|
||||||
|
configured: placement ? 0 : 1,
|
||||||
|
id: module.id,
|
||||||
|
order: placement?.sortOrder ?? 0,
|
||||||
|
};
|
||||||
|
const groupId =
|
||||||
|
placement?.groupId !== undefined &&
|
||||||
|
placement.groupId !== null &&
|
||||||
|
groupIds.has(placement.groupId)
|
||||||
|
? placement.groupId
|
||||||
|
: undefined;
|
||||||
|
if (groupId === undefined) {
|
||||||
|
ungrouped.push({ key, module });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bucket = modulesByGroupId.get(groupId);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.push({ key, module });
|
||||||
|
} else {
|
||||||
|
modulesByGroupId.set(groupId, [{ key, module }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortModules = (entries: Array<{ key: SortKey; module: Module }>): readonly Module[] =>
|
||||||
|
Object.freeze(
|
||||||
|
[...entries]
|
||||||
|
.sort((left, right) => compareSortKeys(left.key, right.key))
|
||||||
|
.map((entry) => entry.module),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = [...configuration.groups]
|
||||||
|
.sort((left, right) =>
|
||||||
|
left.sortOrder !== right.sortOrder
|
||||||
|
? left.sortOrder - right.sortOrder
|
||||||
|
: left.id < right.id
|
||||||
|
? -1
|
||||||
|
: left.id > right.id
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
.map((group) =>
|
||||||
|
Object.freeze({
|
||||||
|
id: group.id,
|
||||||
|
label: group.label,
|
||||||
|
modules: sortModules(modulesByGroupId.get(group.id) ?? []),
|
||||||
|
order: group.sortOrder,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
groups: Object.freeze(groups),
|
||||||
|
ungrouped: sortModules(ungrouped),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { check, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core-owned storage for how the Ekklesia Dashboard *presents* installed modules.
|
||||||
|
*
|
||||||
|
* This is presentation configuration, and it is deliberately the only thing here. A group owns no
|
||||||
|
* tables, no migrations, no routes, and no lifecycle; it never participates in dependency
|
||||||
|
* resolution, discovery order, or module startup. **Modules stay flat** (ADR 0021): there is no
|
||||||
|
* parent module, no child module, no owning module, and nothing in this schema is ever read by
|
||||||
|
* anything that decides what a module may do or when it starts. Moving a module between groups
|
||||||
|
* changes a row here and nothing else.
|
||||||
|
*
|
||||||
|
* Core stores this; it renders nothing. `surfaces/shell` decides what the arrangement looks like.
|
||||||
|
*
|
||||||
|
* Platform-global, because Ekklesia has no organization or tenant model to scope it to. When one
|
||||||
|
* exists, these two tables gain a scope column; inventing a second notion of organization here to
|
||||||
|
* anticipate it would be worse than adding a column later.
|
||||||
|
*
|
||||||
|
* Table and index names are explicit and namespaced exactly like `core/src/authorization/schema.ts`,
|
||||||
|
* so ownership is unambiguous inside the shared public PostgreSQL schema.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Dashboard navigation group — "Media", "Planning", "Sunday Production".
|
||||||
|
*
|
||||||
|
* `id` is an **opaque, stable key**, never displayed and never re-derived from `label`. Renaming a
|
||||||
|
* group changes only `label`, so every placement pointing at it survives untouched; that invariant
|
||||||
|
* is what keeps a rename from silently re-organizing the Dashboard. A slug is used rather than a
|
||||||
|
* generated UUID purely so the configuration API and its tests stay legible — it carries no meaning
|
||||||
|
* the platform reads.
|
||||||
|
*
|
||||||
|
* There is no parent group column. Nesting is deliberately out of scope, and leaving the shape flat
|
||||||
|
* is what keeps it out.
|
||||||
|
*/
|
||||||
|
export const ekklesiaCoreDashboardGroup = pgTable(
|
||||||
|
"ekklesia_core_dashboard_group",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
label: text("label").notNull(),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ekklesia_core_dashboard_group_order_idx").on(table.sortOrder, table.id),
|
||||||
|
// Defense in depth beneath the application-layer identifier and label contracts, which never
|
||||||
|
// produce either of these: an empty id would be an unaddressable group, and an empty or
|
||||||
|
// whitespace-only label would render as a blank heading no administrator could find again.
|
||||||
|
check("ekklesia_core_dashboard_group_id_check", sql`length(${table.id}) > 0`),
|
||||||
|
check("ekklesia_core_dashboard_group_label_check", sql`length(btrim(${table.label})) > 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one module sits in the Dashboard. One row per module at most — the primary key enforces it
|
||||||
|
* — so a module can never be placed in two groups, which is the database-level expression of "one
|
||||||
|
* level of grouping, and no module ownership graph."
|
||||||
|
*
|
||||||
|
* `group_id` is nullable and that is a real state, not a missing value: **ungrouped is a placement**,
|
||||||
|
* chosen by an administrator who ordered a module outside every group. A module with no row at all
|
||||||
|
* is a different state — never configured — and both render in the same ungrouped area (see
|
||||||
|
* `../dashboard/navigation.js` for the exact ordering rule).
|
||||||
|
*
|
||||||
|
* `on delete set null` is what makes group deletion safe: deleting a group cannot leave a dangling
|
||||||
|
* reference, and its modules become ungrouped rather than disappearing. That is enforced by the
|
||||||
|
* database, not by application cleanup that could be skipped.
|
||||||
|
*
|
||||||
|
* `module_id` deliberately carries **no foreign key to any module**, because modules are not rows —
|
||||||
|
* they are packages discovered on disk at startup. A placement for a module that is not installed
|
||||||
|
* is therefore normal and is retained: uninstalling and reinstalling a module keeps its position
|
||||||
|
* rather than silently resetting it. Nothing renders such a row (see `resolveDashboardNavigation`).
|
||||||
|
*/
|
||||||
|
export const ekklesiaCoreDashboardPlacement = pgTable(
|
||||||
|
"ekklesia_core_dashboard_placement",
|
||||||
|
{
|
||||||
|
moduleId: text("module_id").primaryKey(),
|
||||||
|
groupId: text("group_id").references(() => ekklesiaCoreDashboardGroup.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ekklesia_core_dashboard_placement_group_idx").on(table.groupId, table.sortOrder),
|
||||||
|
check("ekklesia_core_dashboard_placement_module_check", sql`length(${table.moduleId}) > 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Explicit map passed to Drizzle, mirroring `authorizationSchema`'s explicit-mapping style. */
|
||||||
|
export const dashboardSchema = {
|
||||||
|
dashboardGroup: ekklesiaCoreDashboardGroup,
|
||||||
|
dashboardPlacement: ekklesiaCoreDashboardPlacement,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
import {
|
||||||
|
dashboardSchema,
|
||||||
|
ekklesiaCoreDashboardGroup,
|
||||||
|
ekklesiaCoreDashboardPlacement,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
export type DashboardDrizzleDatabase = NodePgDatabase<typeof dashboardSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`:
|
||||||
|
* it never constructs, registers an error listener on, or closes a pool. The caller owns the pool
|
||||||
|
* once, however many typed views it creates over it.
|
||||||
|
*/
|
||||||
|
export function createDashboardDrizzleView(pool: Pool): DashboardDrizzleDatabase {
|
||||||
|
return drizzle(pool, { schema: dashboardSchema });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounds every stored ordering value. Wide enough that an administrator never runs out of room
|
||||||
|
* between two entries, small enough that a client cannot store a value that surprises a later
|
||||||
|
* integer column. Enforced identically by the store and by the HTTP surface, so there is one bound
|
||||||
|
* rather than two that could drift. */
|
||||||
|
export const DASHBOARD_SORT_ORDER_MIN = 0;
|
||||||
|
export const DASHBOARD_SORT_ORDER_MAX = 1_000_000;
|
||||||
|
|
||||||
|
/** The label an administrator sees. Bounded like every other operator-facing string in the
|
||||||
|
* platform; `moduleManifestSchema.name` uses the same ceiling. */
|
||||||
|
export const DASHBOARD_GROUP_LABEL_MAX_LENGTH = 120;
|
||||||
|
|
||||||
|
/** Lowercase kebab-case, matching `moduleIdSchema`'s grammar so the platform has one identifier
|
||||||
|
* shape rather than two. Opaque: never displayed, never parsed for meaning, never re-derived from a
|
||||||
|
* label. */
|
||||||
|
const GROUP_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||||
|
const GROUP_ID_MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
/** The one identifier grammar for both a group id and a placement's module id, exported so the HTTP
|
||||||
|
* boundary rejects malformed input with a request error *before* reaching the store rather than
|
||||||
|
* relying on mapping a store failure back to a status code — and so there is one grammar rather
|
||||||
|
* than two that could drift. */
|
||||||
|
export function isValidDashboardIdentifier(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && value.length <= GROUP_ID_MAX_LENGTH && GROUP_ID_PATTERN.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one label rule, exported for the same reason. Returns the trimmed value the store would
|
||||||
|
* persist, or `undefined` when the label is empty, whitespace-only, or too long. */
|
||||||
|
export function normalizeDashboardGroupLabel(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length === 0 || trimmed.length > DASHBOARD_GROUP_LABEL_MAX_LENGTH
|
||||||
|
? undefined
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardStoreErrorCode =
|
||||||
|
| "DASHBOARD_GROUP_ID_INVALID"
|
||||||
|
| "DASHBOARD_GROUP_LABEL_INVALID"
|
||||||
|
| "DASHBOARD_GROUP_NOT_FOUND"
|
||||||
|
| "DASHBOARD_MODULE_ID_INVALID"
|
||||||
|
| "DASHBOARD_SORT_ORDER_INVALID"
|
||||||
|
| "DASHBOARD_READ_FAILED"
|
||||||
|
| "DASHBOARD_WRITE_FAILED";
|
||||||
|
|
||||||
|
const storeErrorMessages: Readonly<Record<DashboardStoreErrorCode, string>> = Object.freeze({
|
||||||
|
DASHBOARD_GROUP_ID_INVALID: "A Dashboard group identifier must be lowercase kebab-case.",
|
||||||
|
DASHBOARD_GROUP_LABEL_INVALID: "A Dashboard group label must be non-empty and bounded.",
|
||||||
|
DASHBOARD_GROUP_NOT_FOUND: "The Dashboard group was not found.",
|
||||||
|
DASHBOARD_MODULE_ID_INVALID: "A Dashboard placement module identifier is invalid.",
|
||||||
|
DASHBOARD_READ_FAILED: "The Dashboard navigation configuration could not be read.",
|
||||||
|
DASHBOARD_SORT_ORDER_INVALID: "A Dashboard ordering value is out of range.",
|
||||||
|
DASHBOARD_WRITE_FAILED: "The Dashboard navigation configuration could not be saved.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Never carries the underlying database error, SQL text, host, or stack — matching
|
||||||
|
* `AuthorizationStoreError` exactly. */
|
||||||
|
export class DashboardStoreError extends Error {
|
||||||
|
readonly code: DashboardStoreErrorCode;
|
||||||
|
|
||||||
|
constructor(code: DashboardStoreErrorCode) {
|
||||||
|
super(storeErrorMessages[code]);
|
||||||
|
this.name = "DashboardStoreError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored group. `label` is what an administrator sees; `id` is the stable key placements point
|
||||||
|
* at, and a rename never touches it. */
|
||||||
|
export interface DashboardGroupRecord {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where one module sits. `groupId: null` is a real, chosen state — deliberately ungrouped — and is
|
||||||
|
* distinct from having no record at all, which means never configured. */
|
||||||
|
export interface DashboardPlacementRecord {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly groupId: string | null;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete stored arrangement. Contains no module facts whatsoever: resolving it against the
|
||||||
|
* modules that actually exist is `resolveDashboardNavigation`'s job, not the store's. */
|
||||||
|
export interface DashboardConfiguration {
|
||||||
|
readonly groups: readonly DashboardGroupRecord[];
|
||||||
|
readonly placements: readonly DashboardPlacementRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertDashboardGroupInput {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetDashboardPlacementInput {
|
||||||
|
readonly moduleId: string;
|
||||||
|
/** `null` places the module in the ungrouped area deliberately. A group id that does not exist is
|
||||||
|
* rejected rather than stored, so a placement can never point at a group that was never created. */
|
||||||
|
readonly groupId: string | null;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStore {
|
||||||
|
/** The whole stored arrangement, in deterministic order. Small by nature — one row per group and
|
||||||
|
* at most one per module — so it is read whole rather than paginated. */
|
||||||
|
readConfiguration(): Promise<DashboardConfiguration>;
|
||||||
|
/** Creates the group or replaces its label and order. Idempotent: the same input twice leaves the
|
||||||
|
* same single row, and placements pointing at it are untouched either way. */
|
||||||
|
upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord>;
|
||||||
|
/** Deletes a group. Its placements become ungrouped through the schema's `on delete set null`, so
|
||||||
|
* no dangling reference is possible and no module is lost. */
|
||||||
|
deleteGroup(groupId: string): Promise<void>;
|
||||||
|
/** Creates or replaces one module's placement. */
|
||||||
|
setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidGroupId(value: string): void {
|
||||||
|
if (!isValidDashboardIdentifier(value)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_ID_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidModuleId(value: string): void {
|
||||||
|
if (!isValidDashboardIdentifier(value)) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_MODULE_ID_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedLabel(value: string): string {
|
||||||
|
const trimmed = normalizeDashboardGroupLabel(value);
|
||||||
|
if (trimmed === undefined) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_LABEL_INVALID");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidSortOrder(value: number): void {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < DASHBOARD_SORT_ORDER_MIN ||
|
||||||
|
value > DASHBOARD_SORT_ORDER_MAX
|
||||||
|
) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_SORT_ORDER_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rethrowAsStoreError(error: unknown, code: DashboardStoreErrorCode): never {
|
||||||
|
if (error instanceof DashboardStoreError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new DashboardStoreError(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's persistence for Dashboard navigation configuration.
|
||||||
|
*
|
||||||
|
* Validation lives here rather than only at the HTTP boundary, so the invariants hold for any
|
||||||
|
* caller: identifiers are checked against one grammar, labels are trimmed and bounded, ordering is
|
||||||
|
* a bounded safe integer, and a placement may only name a group that exists. Nothing here reads,
|
||||||
|
* imports, or reasons about a module — a placement is a `module_id` string and no more — which is
|
||||||
|
* what keeps grouping structurally incapable of influencing discovery, dependency resolution, or
|
||||||
|
* startup order.
|
||||||
|
*/
|
||||||
|
export function createDashboardStore(db: DashboardDrizzleDatabase): DashboardStore {
|
||||||
|
return Object.freeze({
|
||||||
|
async readConfiguration(): Promise<DashboardConfiguration> {
|
||||||
|
try {
|
||||||
|
const [groups, placements] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: ekklesiaCoreDashboardGroup.id,
|
||||||
|
label: ekklesiaCoreDashboardGroup.label,
|
||||||
|
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDashboardGroup)
|
||||||
|
.orderBy(asc(ekklesiaCoreDashboardGroup.sortOrder), asc(ekklesiaCoreDashboardGroup.id)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
groupId: ekklesiaCoreDashboardPlacement.groupId,
|
||||||
|
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
|
||||||
|
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDashboardPlacement)
|
||||||
|
.orderBy(
|
||||||
|
asc(ekklesiaCoreDashboardPlacement.sortOrder),
|
||||||
|
asc(ekklesiaCoreDashboardPlacement.moduleId),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return Object.freeze({
|
||||||
|
groups: Object.freeze(groups.map((group) => Object.freeze({ ...group }))),
|
||||||
|
placements: Object.freeze(placements.map((placement) => Object.freeze({ ...placement }))),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "DASHBOARD_READ_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
|
||||||
|
assertValidGroupId(input.id);
|
||||||
|
const label = normalizedLabel(input.label);
|
||||||
|
assertValidSortOrder(input.sortOrder);
|
||||||
|
try {
|
||||||
|
// A rename updates `label` and `sort_order` only. `id` is the conflict target, so it is
|
||||||
|
// never rewritten and every placement pointing at this group keeps pointing at it.
|
||||||
|
const [row] = await db
|
||||||
|
.insert(ekklesiaCoreDashboardGroup)
|
||||||
|
.values({ id: input.id, label, sortOrder: input.sortOrder })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
set: { label, sortOrder: input.sortOrder, updatedAt: new Date() },
|
||||||
|
target: ekklesiaCoreDashboardGroup.id,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: ekklesiaCoreDashboardGroup.id,
|
||||||
|
label: ekklesiaCoreDashboardGroup.label,
|
||||||
|
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
return Object.freeze(row);
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteGroup(groupId: string): Promise<void> {
|
||||||
|
assertValidGroupId(groupId);
|
||||||
|
try {
|
||||||
|
const deleted = await db
|
||||||
|
.delete(ekklesiaCoreDashboardGroup)
|
||||||
|
.where(eq(ekklesiaCoreDashboardGroup.id, groupId))
|
||||||
|
.returning({ id: ekklesiaCoreDashboardGroup.id });
|
||||||
|
if (deleted.length === 0) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
|
||||||
|
assertValidModuleId(input.moduleId);
|
||||||
|
assertValidSortOrder(input.sortOrder);
|
||||||
|
if (input.groupId !== null) {
|
||||||
|
assertValidGroupId(input.groupId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Checked in the application rather than left to the foreign key so the caller gets
|
||||||
|
// `DASHBOARD_GROUP_NOT_FOUND` instead of a driver error. The foreign key remains the
|
||||||
|
// authority: a group deleted concurrently still fails the insert rather than storing a
|
||||||
|
// dangling reference.
|
||||||
|
if (input.groupId !== null) {
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: ekklesiaCoreDashboardGroup.id })
|
||||||
|
.from(ekklesiaCoreDashboardGroup)
|
||||||
|
.where(eq(ekklesiaCoreDashboardGroup.id, input.groupId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [row] = await db
|
||||||
|
.insert(ekklesiaCoreDashboardPlacement)
|
||||||
|
.values({
|
||||||
|
groupId: input.groupId,
|
||||||
|
moduleId: input.moduleId,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
set: { groupId: input.groupId, sortOrder: input.sortOrder, updatedAt: new Date() },
|
||||||
|
target: ekklesiaCoreDashboardPlacement.moduleId,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
groupId: ekklesiaCoreDashboardPlacement.groupId,
|
||||||
|
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
|
||||||
|
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
return Object.freeze(row);
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
import { deviceSchema } from "./schema.js";
|
||||||
|
|
||||||
|
export type DeviceDrizzleDatabase = NodePgDatabase<typeof deviceSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`.
|
||||||
|
*
|
||||||
|
* There is deliberately no pool-constructing counterpart: device identity is only ever read and
|
||||||
|
* written by the running API, which already owns one validated pool. A second constructor would be
|
||||||
|
* a second place for transport policy to drift.
|
||||||
|
*/
|
||||||
|
export function createDeviceDrizzleView(pool: Pool): DeviceDrizzleDatabase {
|
||||||
|
return drizzle(pool, { schema: deviceSchema });
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./database.js";
|
||||||
|
export * from "./schema.js";
|
||||||
|
export * from "./store.js";
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { relations, sql } from "drizzle-orm";
|
||||||
|
import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core-owned device identity: paired display devices, the permissions granted to them, and the
|
||||||
|
* short-lived pairing requests that create them (ADR 0036).
|
||||||
|
*
|
||||||
|
* **Devices are not users.** There is deliberately no foreign key from a device to
|
||||||
|
* `ekklesia_core_user`, and no device row ever appears in the identity tables: a device is its own
|
||||||
|
* principal class with its own credential and its own explicit permissions. The only reference to a
|
||||||
|
* user here is *who approved a pairing*, which is audit information about a person's action, not a
|
||||||
|
* device's identity.
|
||||||
|
*
|
||||||
|
* Device permissions live in their own table rather than in `ekklesia_core_grant`. That table's
|
||||||
|
* `user_id` is a non-null foreign key into the user table, and generalizing it to a polymorphic
|
||||||
|
* subject would rewrite the platform's central authorization record — and its relationship-derived
|
||||||
|
* grants, audit semantics, and indexes — to serve one narrow case. A separate table keeps the
|
||||||
|
* device story additive and auditable while the evaluator remains the single place any decision is
|
||||||
|
* made (`core/src/authorization/evaluator.ts`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ekklesiaCoreDevice = pgTable(
|
||||||
|
"ekklesia_core_device",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
/** Assigned by the approving administrator, never self-asserted by the unpaired device. */
|
||||||
|
name: text("name").notNull(),
|
||||||
|
/**
|
||||||
|
* SHA-256 of the device's credential, hex encoded. The raw credential exists only in the
|
||||||
|
* response that sets the cookie and in the device's own cookie jar; it is never stored, logged,
|
||||||
|
* or recoverable from this row. A fast hash is right here — unlike a password, the credential
|
||||||
|
* is 256 bits of CSPRNG output, so there is nothing to brute-force and a slow KDF would only
|
||||||
|
* add latency to every request a display makes.
|
||||||
|
*/
|
||||||
|
credentialHash: text("credential_hash").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
createdByUserId: text("created_by_user_id").references(() => ekklesiaCoreUser.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
/** Coarse operational signal, throttled by the resolver; never an identity input. */
|
||||||
|
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
||||||
|
/** Server-side authority. Revocation is this column, never the deletion of a remote cookie. */
|
||||||
|
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||||
|
revokedByUserId: text("revoked_by_user_id").references(() => ekklesiaCoreUser.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// Every credential is distinct, and the resolver looks a presented credential up by this exact
|
||||||
|
// hash — an index probe rather than a scan-and-compare, so there is no comparison to time.
|
||||||
|
uniqueIndex("ekklesia_core_device_credential_hash_idx").on(table.credentialHash),
|
||||||
|
index("ekklesia_core_device_revoked_idx").on(table.revokedAt),
|
||||||
|
check("ekklesia_core_device_name_check", sql`length(${table.name}) between 1 and 128`),
|
||||||
|
check("ekklesia_core_device_credential_hash_check", sql`length(${table.credentialHash}) = 64`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ekklesiaCoreDevicePermission = pgTable(
|
||||||
|
"ekklesia_core_device_permission",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
deviceId: uuid("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => ekklesiaCoreDevice.id, { onDelete: "cascade" }),
|
||||||
|
permissionId: text("permission_id").notNull(),
|
||||||
|
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
grantedByUserId: text("granted_by_user_id").references(() => ekklesiaCoreUser.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("ekklesia_core_device_permission_lookup_idx").on(table.deviceId, table.permissionId),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_device_permission_id_check",
|
||||||
|
sql`length(${table.permissionId}) between 1 and 128`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One in-flight pairing attempt.
|
||||||
|
*
|
||||||
|
* Three distinct secrets meet here and must not be confused:
|
||||||
|
*
|
||||||
|
* - `code` is the short digits shown on the screen. It is **public by design** — it is displayed on
|
||||||
|
* a television in a hallway — and it identifies the request to an administrator. It is stored in
|
||||||
|
* the clear because it is not a secret and because an administrator looks a request up by it.
|
||||||
|
* - `claimSecretHash` is SHA-256 of a 256-bit secret held only in the requesting browser's
|
||||||
|
* HttpOnly cookie. It answers "are you the device that asked?", which the code cannot: anyone who
|
||||||
|
* can *see* the screen knows the code, so a flow that issued a credential on the strength of the
|
||||||
|
* code alone would hand the device's identity to whoever read it off the wall.
|
||||||
|
* - The device credential does not exist yet, and is created only when the approved request is
|
||||||
|
* consumed by the browser holding the claim secret.
|
||||||
|
*
|
||||||
|
* `expiresAt` is the only expiry authority; there is no `expired` state to write and therefore no
|
||||||
|
* sweeper that must run for correctness. `state` records only what someone *did* — nothing here
|
||||||
|
* ages a row on its own.
|
||||||
|
*/
|
||||||
|
export const ekklesiaCoreDevicePairingRequest = pgTable(
|
||||||
|
"ekklesia_core_device_pairing_request",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
code: text("code").notNull(),
|
||||||
|
claimSecretHash: text("claim_secret_hash").notNull(),
|
||||||
|
/** The display the device actually navigated to, resolved server-side from the publication
|
||||||
|
* registry. Never a value the browser chose. */
|
||||||
|
moduleId: text("module_id").notNull(),
|
||||||
|
/** Derived from that module's validated display declaration, so a device cannot ask for a
|
||||||
|
* permission of its choosing. */
|
||||||
|
permissionId: text("permission_id").notNull(),
|
||||||
|
state: text("state").notNull().default("pending"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
/** Chosen by the approving administrator; carried here until the request is consumed and the
|
||||||
|
* device row is created. */
|
||||||
|
deviceName: text("device_name"),
|
||||||
|
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||||
|
approvedByUserId: text("approved_by_user_id").references(() => ekklesiaCoreUser.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
consumedAt: timestamp("consumed_at", { withTimezone: true }),
|
||||||
|
deviceId: uuid("device_id").references(() => ekklesiaCoreDevice.id, { onDelete: "set null" }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// At most one live request may hold a given code, which is what makes a short code safe to
|
||||||
|
// generate: a collision is refused by PostgreSQL rather than resolved by whichever row a query
|
||||||
|
// happened to return first. Consumed rows keep their code without blocking reuse later.
|
||||||
|
uniqueIndex("ekklesia_core_device_pairing_code_idx")
|
||||||
|
.on(table.code)
|
||||||
|
.where(sql`${table.state} <> 'consumed'`),
|
||||||
|
index("ekklesia_core_device_pairing_claim_idx").on(table.claimSecretHash),
|
||||||
|
check("ekklesia_core_device_pairing_code_check", sql`${table.code} ~ '^[0-9]{6}$'`),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_device_pairing_claim_hash_check",
|
||||||
|
sql`length(${table.claimSecretHash}) = 64`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"ekklesia_core_device_pairing_state_check",
|
||||||
|
sql`${table.state} in ('pending', 'approved', 'consumed')`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ekklesiaCoreDeviceRelations = relations(ekklesiaCoreDevice, ({ many }) => ({
|
||||||
|
permissions: many(ekklesiaCoreDevicePermission),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ekklesiaCoreDevicePermissionRelations = relations(
|
||||||
|
ekklesiaCoreDevicePermission,
|
||||||
|
({ one }) => ({
|
||||||
|
device: one(ekklesiaCoreDevice, {
|
||||||
|
fields: [ekklesiaCoreDevicePermission.deviceId],
|
||||||
|
references: [ekklesiaCoreDevice.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The device tables, bundled exactly like `authorizationSchema`, so a Drizzle view over the
|
||||||
|
* platform's shared pool is typed against them. */
|
||||||
|
export const deviceSchema = {
|
||||||
|
ekklesiaCoreDevice,
|
||||||
|
ekklesiaCoreDevicePairingRequest,
|
||||||
|
ekklesiaCoreDevicePermission,
|
||||||
|
ekklesiaCoreDevicePermissionRelations,
|
||||||
|
ekklesiaCoreDeviceRelations,
|
||||||
|
};
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
||||||
|
import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||||
|
import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
|
||||||
|
import type { DeviceDrizzleDatabase } from "./database.js";
|
||||||
|
import {
|
||||||
|
ekklesiaCoreDevice,
|
||||||
|
ekklesiaCoreDevicePairingRequest,
|
||||||
|
ekklesiaCoreDevicePermission,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
export type DeviceStoreErrorCode =
|
||||||
|
| "DEVICE_PAIRING_CODE_UNAVAILABLE"
|
||||||
|
| "DEVICE_STORE_FAILED"
|
||||||
|
| "DEVICE_NOT_FOUND";
|
||||||
|
|
||||||
|
const deviceStoreMessages: Readonly<Record<DeviceStoreErrorCode, string>> = Object.freeze({
|
||||||
|
DEVICE_NOT_FOUND: "The device does not exist.",
|
||||||
|
DEVICE_PAIRING_CODE_UNAVAILABLE: "A pairing code could not be allocated. Try again.",
|
||||||
|
DEVICE_STORE_FAILED: "The device operation could not be completed.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Never carries a credential, a claim secret, SQL, a driver error, or a cause. */
|
||||||
|
export class DeviceStoreError extends Error {
|
||||||
|
readonly code: DeviceStoreErrorCode;
|
||||||
|
|
||||||
|
constructor(code: DeviceStoreErrorCode) {
|
||||||
|
super(deviceStoreMessages[code]);
|
||||||
|
this.name = "DeviceStoreError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a pairing code is worth showing on a screen.
|
||||||
|
*
|
||||||
|
* Long enough that someone can walk from the display to a computer and type six digits; short
|
||||||
|
* enough that a code photographed off a wall is worthless by the time anyone acts on it. It is not
|
||||||
|
* the only thing protecting pairing — approval requires an authenticated administrator holding
|
||||||
|
* `core.devices.manage`, and the credential is issued only to the browser holding the claim secret
|
||||||
|
* — so this bound is defence in depth rather than the defence.
|
||||||
|
*/
|
||||||
|
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a device credential is accepted before the device must pair again.
|
||||||
|
*
|
||||||
|
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
||||||
|
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
||||||
|
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
||||||
|
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
||||||
|
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
||||||
|
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
|
||||||
|
*/
|
||||||
|
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
||||||
|
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||||
|
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||||
|
|
||||||
|
const PAIRING_CODE_ALLOCATION_ATTEMPTS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many pairing requests may be waiting at once, across the whole installation.
|
||||||
|
*
|
||||||
|
* Starting a pairing needs no identity — an unpaired screen has none to offer — so without a ceiling
|
||||||
|
* anyone who can reach the platform could fill this table. A building has a handful of screens and
|
||||||
|
* pairs them one at a time, so this is far above any real use and still a hard bound. Expired rows
|
||||||
|
* are swept on the way in, so the ceiling counts genuinely live requests rather than history.
|
||||||
|
*/
|
||||||
|
const MAX_LIVE_PAIRING_REQUESTS = 50;
|
||||||
|
|
||||||
|
/** SHA-256, hex. Applied to 256-bit CSPRNG output, never to anything user-chosen. */
|
||||||
|
export function hashDeviceSecret(secret: string): string {
|
||||||
|
return createHash("sha256").update(secret, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 256 bits, URL-safe. Used for both the pairing claim secret and the device credential — the two
|
||||||
|
* values that must never be guessable. */
|
||||||
|
function generateSecret(): string {
|
||||||
|
return randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Six digits from a rejection-free CSPRNG range, zero padded so every code is exactly six
|
||||||
|
* characters and none is easier to type than another. */
|
||||||
|
function generatePairingCode(): string {
|
||||||
|
return randomInt(0, 1_000_000).toString().padStart(6, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePairingRequestInput {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly permissionId: PermissionId;
|
||||||
|
readonly now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedPairingRequest {
|
||||||
|
readonly code: string;
|
||||||
|
readonly expiresAt: Date;
|
||||||
|
/** Returned exactly once, to be written into the device's HttpOnly cookie and never persisted or
|
||||||
|
* logged in this form. */
|
||||||
|
readonly claimSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown";
|
||||||
|
|
||||||
|
export interface PairingRequestView {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly permissionId: string;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly expiresAt: Date;
|
||||||
|
readonly status: "pending" | "approved";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsumedPairing {
|
||||||
|
readonly deviceId: EkklesiaDeviceId;
|
||||||
|
/** Returned exactly once, to be written into the device's HttpOnly cookie. */
|
||||||
|
readonly credential: string;
|
||||||
|
readonly expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceSummary {
|
||||||
|
readonly id: EkklesiaDeviceId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly lastSeenAt?: Date;
|
||||||
|
readonly revokedAt?: Date;
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedDevice {
|
||||||
|
readonly deviceId: EkklesiaDeviceId;
|
||||||
|
readonly lastSeenAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceStore {
|
||||||
|
createPairingRequest(input: CreatePairingRequestInput): Promise<CreatedPairingRequest>;
|
||||||
|
/** Looks a live request up by the code an administrator typed. Never used by the device itself. */
|
||||||
|
findPendingByCode(code: string, now: Date): Promise<PairingRequestView | undefined>;
|
||||||
|
approvePairingRequest(input: {
|
||||||
|
readonly code: string;
|
||||||
|
readonly deviceName: string;
|
||||||
|
readonly approvedBy: EkklesiaUserId;
|
||||||
|
readonly now: Date;
|
||||||
|
}): Promise<PairingRequestView | undefined>;
|
||||||
|
/** The device's own poll. Consumes an approved request atomically and issues the credential. */
|
||||||
|
claimPairingRequest(input: {
|
||||||
|
readonly claimSecret: string;
|
||||||
|
readonly now: Date;
|
||||||
|
}): Promise<{ readonly status: PairingRequestStatus; readonly paired?: ConsumedPairing }>;
|
||||||
|
/** Resolves a presented credential to a live, unrevoked device. */
|
||||||
|
resolveCredential(credential: string, now: Date): Promise<ResolvedDevice | undefined>;
|
||||||
|
touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise<void>;
|
||||||
|
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
|
||||||
|
listDevices(): Promise<readonly DeviceSummary[]>;
|
||||||
|
revokeDevice(input: {
|
||||||
|
readonly deviceId: EkklesiaDeviceId;
|
||||||
|
readonly revokedBy: EkklesiaUserId;
|
||||||
|
readonly now: Date;
|
||||||
|
}): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rethrowAsStoreError(error: unknown): never {
|
||||||
|
if (error instanceof DeviceStoreError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new DeviceStoreError("DEVICE_STORE_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||||
|
/** Deletes rows that can no longer do anything: expired-and-never-approved requests, and
|
||||||
|
* consumed ones past their window. Run opportunistically on request creation rather than by a
|
||||||
|
* scheduler — pairing is rare, so the only moment the table can grow is also a fine moment to
|
||||||
|
* trim it, and nothing depends on it having run. */
|
||||||
|
const sweepDeadRequests = async (now: Date): Promise<void> => {
|
||||||
|
await db
|
||||||
|
.delete(ekklesiaCoreDevicePairingRequest)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(
|
||||||
|
lt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
|
||||||
|
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||||
|
),
|
||||||
|
lt(ekklesiaCoreDevicePairingRequest.expiresAt, new Date(now.getTime() - 86_400_000)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const store: DeviceStore = {
|
||||||
|
async approvePairingRequest(input): Promise<PairingRequestView | undefined> {
|
||||||
|
try {
|
||||||
|
// Conditional on `pending` and unexpired, so approving twice, approving an expired request,
|
||||||
|
// or approving one already consumed all affect zero rows rather than racing.
|
||||||
|
const rows = await db
|
||||||
|
.update(ekklesiaCoreDevicePairingRequest)
|
||||||
|
.set({
|
||||||
|
approvedAt: input.now,
|
||||||
|
approvedByUserId: input.approvedBy,
|
||||||
|
deviceName: input.deviceName,
|
||||||
|
state: "approved",
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevicePairingRequest.code, input.code),
|
||||||
|
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||||
|
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({
|
||||||
|
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
|
||||||
|
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
|
||||||
|
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
|
||||||
|
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
|
||||||
|
});
|
||||||
|
const row = rows[0];
|
||||||
|
return row === undefined
|
||||||
|
? undefined
|
||||||
|
: Object.freeze({ ...row, status: "approved" as const });
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async claimPairingRequest(input): Promise<{
|
||||||
|
readonly status: PairingRequestStatus;
|
||||||
|
readonly paired?: ConsumedPairing;
|
||||||
|
}> {
|
||||||
|
const claimSecretHash = hashDeviceSecret(input.claimSecret);
|
||||||
|
try {
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
const rows = await tx
|
||||||
|
.select({
|
||||||
|
deviceName: ekklesiaCoreDevicePairingRequest.deviceName,
|
||||||
|
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
|
||||||
|
id: ekklesiaCoreDevicePairingRequest.id,
|
||||||
|
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
|
||||||
|
state: ekklesiaCoreDevicePairingRequest.state,
|
||||||
|
approvedByUserId: ekklesiaCoreDevicePairingRequest.approvedByUserId,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDevicePairingRequest)
|
||||||
|
.where(eq(ekklesiaCoreDevicePairingRequest.claimSecretHash, claimSecretHash))
|
||||||
|
.limit(1);
|
||||||
|
const request = rows[0];
|
||||||
|
if (!request) {
|
||||||
|
return { status: "unknown" as const };
|
||||||
|
}
|
||||||
|
if (request.state === "consumed") {
|
||||||
|
// A credential was already issued for this request. Replaying the claim never issues a
|
||||||
|
// second one.
|
||||||
|
return { status: "unknown" as const };
|
||||||
|
}
|
||||||
|
if (request.expiresAt.getTime() <= input.now.getTime()) {
|
||||||
|
return { status: "expired" as const };
|
||||||
|
}
|
||||||
|
if (request.state !== "approved") {
|
||||||
|
return { status: "pending" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single-use gate: only the transaction whose UPDATE actually matches the still
|
||||||
|
// `approved` row proceeds to mint a credential. A concurrent second claim matches zero
|
||||||
|
// rows and is told the request is gone.
|
||||||
|
const consumed = await tx
|
||||||
|
.update(ekklesiaCoreDevicePairingRequest)
|
||||||
|
.set({ consumedAt: input.now, state: "consumed" })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevicePairingRequest.id, request.id),
|
||||||
|
eq(ekklesiaCoreDevicePairingRequest.state, "approved"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: ekklesiaCoreDevicePairingRequest.id });
|
||||||
|
if (consumed.length === 0) {
|
||||||
|
return { status: "unknown" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = generateSecret();
|
||||||
|
const created = await tx
|
||||||
|
.insert(ekklesiaCoreDevice)
|
||||||
|
.values({
|
||||||
|
createdByUserId: request.approvedByUserId,
|
||||||
|
credentialHash: hashDeviceSecret(credential),
|
||||||
|
name: request.deviceName ?? "Display device",
|
||||||
|
})
|
||||||
|
.returning({ id: ekklesiaCoreDevice.id });
|
||||||
|
const deviceId = created[0]?.id;
|
||||||
|
if (deviceId === undefined) {
|
||||||
|
throw new DeviceStoreError("DEVICE_STORE_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly the one permission the request was created for — never a bundle, never every
|
||||||
|
// permission the module declares.
|
||||||
|
await tx.insert(ekklesiaCoreDevicePermission).values({
|
||||||
|
deviceId,
|
||||||
|
grantedByUserId: request.approvedByUserId,
|
||||||
|
permissionId: request.permissionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(ekklesiaCoreDevicePairingRequest)
|
||||||
|
.set({ deviceId })
|
||||||
|
.where(eq(ekklesiaCoreDevicePairingRequest.id, request.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
paired: Object.freeze({
|
||||||
|
credential,
|
||||||
|
deviceId: deviceId as EkklesiaDeviceId,
|
||||||
|
expiresAt: new Date(input.now.getTime() + DEVICE_CREDENTIAL_TTL_MS),
|
||||||
|
}),
|
||||||
|
status: "pending" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPairingRequest(input): Promise<CreatedPairingRequest> {
|
||||||
|
await sweepDeadRequests(input.now);
|
||||||
|
const live = await db
|
||||||
|
.select({ id: ekklesiaCoreDevicePairingRequest.id })
|
||||||
|
.from(ekklesiaCoreDevicePairingRequest)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
|
||||||
|
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(MAX_LIVE_PAIRING_REQUESTS);
|
||||||
|
if (live.length >= MAX_LIVE_PAIRING_REQUESTS) {
|
||||||
|
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
const claimSecret = generateSecret();
|
||||||
|
const expiresAt = new Date(input.now.getTime() + DEVICE_PAIRING_REQUEST_TTL_MS);
|
||||||
|
// Collisions are resolved by the partial unique index, not by reading first and hoping: a
|
||||||
|
// concurrent request that took the same code makes this insert fail and we simply draw again.
|
||||||
|
for (let attempt = 0; attempt < PAIRING_CODE_ALLOCATION_ATTEMPTS; attempt += 1) {
|
||||||
|
const code = generatePairingCode();
|
||||||
|
try {
|
||||||
|
await db.insert(ekklesiaCoreDevicePairingRequest).values({
|
||||||
|
claimSecretHash: hashDeviceSecret(claimSecret),
|
||||||
|
code,
|
||||||
|
expiresAt,
|
||||||
|
moduleId: input.moduleId,
|
||||||
|
permissionId: input.permissionId,
|
||||||
|
});
|
||||||
|
return Object.freeze({ claimSecret, code, expiresAt });
|
||||||
|
} catch {
|
||||||
|
// Fall through and draw a different code.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
|
||||||
|
},
|
||||||
|
|
||||||
|
async findPendingByCode(code, now): Promise<PairingRequestView | undefined> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
|
||||||
|
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
|
||||||
|
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
|
||||||
|
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
|
||||||
|
state: ekklesiaCoreDevicePairingRequest.state,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDevicePairingRequest)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevicePairingRequest.code, code),
|
||||||
|
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
|
||||||
|
gt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return row === undefined
|
||||||
|
? undefined
|
||||||
|
: Object.freeze({
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
expiresAt: row.expiresAt,
|
||||||
|
moduleId: row.moduleId,
|
||||||
|
permissionId: row.permissionId,
|
||||||
|
status: "pending" as const,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async hasActivePermission(deviceId, permission): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Joined against the device row so a revoked device has no permissions at all, without
|
||||||
|
// every permission row needing to be revoked separately.
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: ekklesiaCoreDevicePermission.id })
|
||||||
|
.from(ekklesiaCoreDevicePermission)
|
||||||
|
.innerJoin(
|
||||||
|
ekklesiaCoreDevice,
|
||||||
|
eq(ekklesiaCoreDevice.id, ekklesiaCoreDevicePermission.deviceId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevicePermission.deviceId, deviceId),
|
||||||
|
eq(ekklesiaCoreDevicePermission.permissionId, permission),
|
||||||
|
isNull(ekklesiaCoreDevicePermission.revokedAt),
|
||||||
|
isNull(ekklesiaCoreDevice.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async listDevices(): Promise<readonly DeviceSummary[]> {
|
||||||
|
try {
|
||||||
|
const devices = await db
|
||||||
|
.select({
|
||||||
|
createdAt: ekklesiaCoreDevice.createdAt,
|
||||||
|
id: ekklesiaCoreDevice.id,
|
||||||
|
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
|
||||||
|
name: ekklesiaCoreDevice.name,
|
||||||
|
revokedAt: ekklesiaCoreDevice.revokedAt,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDevice)
|
||||||
|
.orderBy(ekklesiaCoreDevice.createdAt);
|
||||||
|
const permissions = await db
|
||||||
|
.select({
|
||||||
|
deviceId: ekklesiaCoreDevicePermission.deviceId,
|
||||||
|
permissionId: ekklesiaCoreDevicePermission.permissionId,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDevicePermission)
|
||||||
|
.where(isNull(ekklesiaCoreDevicePermission.revokedAt));
|
||||||
|
return Object.freeze(
|
||||||
|
devices.map((device) =>
|
||||||
|
Object.freeze({
|
||||||
|
createdAt: device.createdAt,
|
||||||
|
id: device.id as EkklesiaDeviceId,
|
||||||
|
name: device.name,
|
||||||
|
permissions: Object.freeze(
|
||||||
|
permissions
|
||||||
|
.filter((permission) => permission.deviceId === device.id)
|
||||||
|
.map((permission) => permission.permissionId)
|
||||||
|
.sort(),
|
||||||
|
),
|
||||||
|
...(device.lastSeenAt === null ? {} : { lastSeenAt: device.lastSeenAt }),
|
||||||
|
...(device.revokedAt === null ? {} : { revokedAt: device.revokedAt }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolveCredential(credential, now): Promise<ResolvedDevice | undefined> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
createdAt: ekklesiaCoreDevice.createdAt,
|
||||||
|
id: ekklesiaCoreDevice.id,
|
||||||
|
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
|
||||||
|
})
|
||||||
|
.from(ekklesiaCoreDevice)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevice.credentialHash, hashDeviceSecret(credential)),
|
||||||
|
isNull(ekklesiaCoreDevice.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||||
|
// checked above and is what makes a long horizon acceptable.
|
||||||
|
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
deviceId: row.id as EkklesiaDeviceId,
|
||||||
|
...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeDevice(input): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.update(ekklesiaCoreDevice)
|
||||||
|
.set({ revokedAt: input.now, revokedByUserId: input.revokedBy })
|
||||||
|
.where(
|
||||||
|
and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)),
|
||||||
|
)
|
||||||
|
.returning({ id: ekklesiaCoreDevice.id });
|
||||||
|
if (rows.length === 0) {
|
||||||
|
// Either no such device or already revoked. Both are "not an active device", and telling
|
||||||
|
// the two apart would only serve enumeration.
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: ekklesiaCoreDevice.id })
|
||||||
|
.from(ekklesiaCoreDevice)
|
||||||
|
.where(eq(ekklesiaCoreDevice.id, input.deviceId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new DeviceStoreError("DEVICE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(ekklesiaCoreDevicePermission)
|
||||||
|
.set({ revokedAt: input.now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId),
|
||||||
|
isNull(ekklesiaCoreDevicePermission.revokedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
rethrowAsStoreError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async touchLastSeen(deviceId, now): Promise<void> {
|
||||||
|
// Throttled in SQL so concurrent requests from one display cannot each decide to write.
|
||||||
|
await db
|
||||||
|
.update(ekklesiaCoreDevice)
|
||||||
|
.set({ lastSeenAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreDevice.id, deviceId),
|
||||||
|
or(
|
||||||
|
isNull(ekklesiaCoreDevice.lastSeenAt),
|
||||||
|
lt(
|
||||||
|
ekklesiaCoreDevice.lastSeenAt,
|
||||||
|
new Date(now.getTime() - DEVICE_LAST_SEEN_THROTTLE_MS),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported for the schema-drift check and tests; never used to build a query string by hand. */
|
||||||
|
export const DEVICE_PAIRING_CODE_PATTERN = /^[0-9]{6}$/u;
|
||||||
|
|
||||||
|
export { sql as deviceStoreSql };
|
||||||
@@ -11,13 +11,33 @@ function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSecti
|
|||||||
|
|
||||||
const sections: readonly DoctorSection[] = Object.freeze([
|
const sections: readonly DoctorSection[] = Object.freeze([
|
||||||
section("Platform", ["platform.runtime"]),
|
section("Platform", ["platform.runtime"]),
|
||||||
section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]),
|
section("Modules", [
|
||||||
|
"modules.discovery",
|
||||||
|
"modules.dependencies",
|
||||||
|
"modules.permissions",
|
||||||
|
"modules.lifecycle",
|
||||||
|
"modules.configuration",
|
||||||
|
]),
|
||||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||||
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||||
section("Identity", ["identity.configuration", "identity.client-address"]),
|
section("Identity", ["identity.configuration", "identity.client-address"]),
|
||||||
section("Object storage", ["object-storage.integration"]),
|
section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
|
||||||
|
section("Outbound email", ["outbound-email.configuration"]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every check id, exactly once.
|
||||||
|
*
|
||||||
|
* A check absent from `sections` is still computed and still counted toward the overall result, but
|
||||||
|
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
|
||||||
|
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
|
||||||
|
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
|
||||||
|
* cannot be added without being rendered.
|
||||||
|
*/
|
||||||
|
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
|
||||||
|
sections.flatMap((entry) => [...entry.checkIds]),
|
||||||
|
);
|
||||||
|
|
||||||
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
|
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
|
||||||
failure: "✗",
|
failure: "✗",
|
||||||
pass: "✓",
|
pass: "✓",
|
||||||
|
|||||||
+661
-11
@@ -1,23 +1,29 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
|
capabilityIdSchema,
|
||||||
|
EMPTY_MODULE_SECRET_SOURCE,
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
|
logicalBucketIdSchema,
|
||||||
|
type ModuleSecretSource,
|
||||||
|
moduleConfigurationKeySchema,
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
permissionIdSchema,
|
permissionIdSchema,
|
||||||
semanticVersionSchema,
|
semanticVersionSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import { getTableName } from "drizzle-orm";
|
import { getTableName } from "drizzle-orm";
|
||||||
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
||||||
|
import type { PostgresPoolOptions } from "../database/pool.js";
|
||||||
import {
|
import {
|
||||||
assertProductionDatabaseTransport,
|
assertProductionDatabaseTransport,
|
||||||
type DatabaseTransportEnvironment,
|
|
||||||
type DatabaseTransportPolicyInput,
|
|
||||||
DatabaseTransportError,
|
|
||||||
createDatabaseTransportPolicy,
|
createDatabaseTransportPolicy,
|
||||||
|
type DatabaseTransportEnvironment,
|
||||||
|
DatabaseTransportError,
|
||||||
|
type DatabaseTransportPolicyInput,
|
||||||
describeDatabaseTransport,
|
describeDatabaseTransport,
|
||||||
} from "../database/transport.js";
|
} from "../database/transport.js";
|
||||||
import {
|
import {
|
||||||
type ClientAddressConfigInput,
|
|
||||||
ClientAddressConfigError,
|
ClientAddressConfigError,
|
||||||
|
type ClientAddressConfigInput,
|
||||||
createClientAddressConfig,
|
createClientAddressConfig,
|
||||||
} from "../identity/client-address.js";
|
} from "../identity/client-address.js";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +33,14 @@ import {
|
|||||||
} from "../identity/config.js";
|
} from "../identity/config.js";
|
||||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
||||||
import { identityAdapterSchema } from "../identity/schema.js";
|
import { identityAdapterSchema } from "../identity/schema.js";
|
||||||
|
import {
|
||||||
|
createMailProviderConfig,
|
||||||
|
createSmtpMailProvider,
|
||||||
|
describeMailProvider,
|
||||||
|
MailConfigError,
|
||||||
|
type MailProviderConfig,
|
||||||
|
type MailProviderConfigInput,
|
||||||
|
} from "../mail/index.js";
|
||||||
import {
|
import {
|
||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
discoverAllMigrations,
|
discoverAllMigrations,
|
||||||
@@ -39,14 +53,34 @@ import {
|
|||||||
} from "../migrations/index.js";
|
} from "../migrations/index.js";
|
||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleConfigurationDatabase,
|
||||||
|
createModuleConfigurationStore,
|
||||||
|
createModuleLifecycleDatabase,
|
||||||
|
createModuleLifecycleStore,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
ModulePermissionCompositionError,
|
isConfigurationBlockedDetail,
|
||||||
|
isStorageBlockedDetail,
|
||||||
|
type ModuleConfigurationSettings,
|
||||||
type ModuleDiscoveryOptions,
|
type ModuleDiscoveryOptions,
|
||||||
|
type ModuleEligibility,
|
||||||
|
type ModuleLifecycleConfiguration,
|
||||||
type ModuleMigrationAggregate,
|
type ModuleMigrationAggregate,
|
||||||
|
ModulePermissionCompositionError,
|
||||||
type ModuleSchemaAggregate,
|
type ModuleSchemaAggregate,
|
||||||
|
resolveModuleEligibility,
|
||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
|
import {
|
||||||
|
createS3StorageProvider,
|
||||||
|
createStorageProviderConfig,
|
||||||
|
describeStorageProvider,
|
||||||
|
physicalBucketName,
|
||||||
|
StorageConfigError,
|
||||||
|
type StorageProviderConfig,
|
||||||
|
type StorageProviderConfigInput,
|
||||||
|
} from "../storage/index.js";
|
||||||
|
|
||||||
export type DoctorCheckId =
|
export type DoctorCheckId =
|
||||||
| "database.transport"
|
| "database.transport"
|
||||||
@@ -54,13 +88,17 @@ export type DoctorCheckId =
|
|||||||
| "modules.discovery"
|
| "modules.discovery"
|
||||||
| "modules.dependencies"
|
| "modules.dependencies"
|
||||||
| "modules.permissions"
|
| "modules.permissions"
|
||||||
|
| "modules.lifecycle"
|
||||||
|
| "modules.configuration"
|
||||||
| "schemas.discovery"
|
| "schemas.discovery"
|
||||||
| "migrations.discovery"
|
| "migrations.discovery"
|
||||||
| "postgresql.connection"
|
| "postgresql.connection"
|
||||||
| "postgresql.plan"
|
| "postgresql.plan"
|
||||||
| "identity.configuration"
|
| "identity.configuration"
|
||||||
| "identity.client-address"
|
| "identity.client-address"
|
||||||
| "object-storage.integration";
|
| "object-storage.integration"
|
||||||
|
| "object-storage.bindings"
|
||||||
|
| "outbound-email.configuration";
|
||||||
|
|
||||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||||
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
|
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
|
||||||
@@ -79,6 +117,7 @@ export interface DoctorReport {
|
|||||||
|
|
||||||
export interface DoctorIdentityConfigurationOptions {
|
export interface DoctorIdentityConfigurationOptions {
|
||||||
readonly baseUrl?: string | undefined;
|
readonly baseUrl?: string | undefined;
|
||||||
|
readonly dashboardOrigin?: string | undefined;
|
||||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||||
readonly publicSignupEnabled?: string | undefined;
|
readonly publicSignupEnabled?: string | undefined;
|
||||||
readonly secret?: string | undefined;
|
readonly secret?: string | undefined;
|
||||||
@@ -94,6 +133,30 @@ export interface DoctorOptions {
|
|||||||
readonly databaseUrl?: string | undefined;
|
readonly databaseUrl?: string | undefined;
|
||||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||||
|
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
|
||||||
|
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports every
|
||||||
|
* required secret as missing, which is only the honest answer for a deployment that has actually
|
||||||
|
* set none, so an executable must pass the same source it would give the runtime. */
|
||||||
|
readonly moduleSecrets?: ModuleSecretSource;
|
||||||
|
/**
|
||||||
|
* The host's object-storage settings as the executable read them, validated here.
|
||||||
|
*
|
||||||
|
* **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider
|
||||||
|
* is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that
|
||||||
|
* validates it and reports the sanitized code. A pre-validated option meant a bad value threw
|
||||||
|
* before a report existed, and — because the executable then had to validate it itself — meant
|
||||||
|
* `pnpm doctor` simply never passed one at all, reporting configured storage as absent.
|
||||||
|
*/
|
||||||
|
readonly storageConfiguration?: StorageProviderConfigInput | undefined;
|
||||||
|
/**
|
||||||
|
* The host's outbound mail settings as the executable read them, validated here.
|
||||||
|
*
|
||||||
|
* Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a
|
||||||
|
* malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to
|
||||||
|
* be the one that validates it and reports the sanitized code. A pre-validated option would mean
|
||||||
|
* a bad value crashed the command instead of appearing in its report.
|
||||||
|
*/
|
||||||
|
readonly mailConfiguration?: MailProviderConfigInput | undefined;
|
||||||
readonly runtimeRange: string;
|
readonly runtimeRange: string;
|
||||||
readonly runtimeVersion: string;
|
readonly runtimeVersion: string;
|
||||||
}
|
}
|
||||||
@@ -113,11 +176,30 @@ export interface DoctorDependencies {
|
|||||||
options: ModuleDiscoveryOptions,
|
options: ModuleDiscoveryOptions,
|
||||||
) => Promise<readonly DiscoveredModule[]>;
|
) => Promise<readonly DiscoveredModule[]>;
|
||||||
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
|
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
|
||||||
|
/** Reads the stored module enablement configuration read-only. Separated behind the dependency
|
||||||
|
* boundary like every other I/O Doctor performs, so the whole report stays exercisable without a
|
||||||
|
* database. */
|
||||||
|
readonly readModuleLifecycle: (
|
||||||
|
connectionString: string,
|
||||||
|
options: PostgresPoolOptions,
|
||||||
|
) => Promise<ModuleLifecycleConfiguration>;
|
||||||
|
/** Reads the stored module configuration read-only, behind the same dependency boundary as every
|
||||||
|
* other I/O Doctor performs. */
|
||||||
|
readonly readModuleConfiguration: (
|
||||||
|
connectionString: string,
|
||||||
|
options: PostgresPoolOptions,
|
||||||
|
) => Promise<ModuleConfigurationSettings>;
|
||||||
readonly planMigrations: (options: {
|
readonly planMigrations: (options: {
|
||||||
readonly aggregate: ModuleMigrationAggregate;
|
readonly aggregate: ModuleMigrationAggregate;
|
||||||
readonly database: MigrationDatabase;
|
readonly database: MigrationDatabase;
|
||||||
}) => Promise<MigrationPlanResult>;
|
}) => Promise<MigrationPlanResult>;
|
||||||
readonly runtimeSatisfies: (version: string, range: string) => boolean;
|
readonly runtimeSatisfies: (version: string, range: string) => boolean;
|
||||||
|
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
|
||||||
|
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
|
||||||
|
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
|
||||||
|
/** Opens an SMTP connection, authenticates when credentials are configured, and closes —
|
||||||
|
* **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */
|
||||||
|
readonly probeMail: (config: MailProviderConfig) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const doctorPostgresTimeoutMs = 10_000;
|
const doctorPostgresTimeoutMs = 10_000;
|
||||||
@@ -134,6 +216,60 @@ async function discoverAllMigrationsForRuntime(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
|
||||||
|
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
|
||||||
|
async function readModuleLifecycleForRuntime(
|
||||||
|
connectionString: string,
|
||||||
|
options: PostgresPoolOptions,
|
||||||
|
): Promise<ModuleLifecycleConfiguration> {
|
||||||
|
const { close, db } = createModuleLifecycleDatabase(connectionString, options);
|
||||||
|
try {
|
||||||
|
return await createModuleLifecycleStore(db).readConfiguration();
|
||||||
|
} finally {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens its own short-lived connection, reads, and always closes it — the same discipline
|
||||||
|
* `readModuleLifecycleForRuntime` follows. */
|
||||||
|
async function readModuleConfigurationForRuntime(
|
||||||
|
connectionString: string,
|
||||||
|
options: PostgresPoolOptions,
|
||||||
|
): Promise<ModuleConfigurationSettings> {
|
||||||
|
const { close, db } = createModuleConfigurationDatabase(connectionString, options);
|
||||||
|
try {
|
||||||
|
return await createModuleConfigurationStore(db).readSettings();
|
||||||
|
} finally {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a provider client, probes, and always closes it — Doctor never leaves a connection behind. */
|
||||||
|
async function probeStorageForRuntime(config: StorageProviderConfig): Promise<void> {
|
||||||
|
const provider = createS3StorageProvider(config);
|
||||||
|
try {
|
||||||
|
await provider.probe();
|
||||||
|
} finally {
|
||||||
|
await provider.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind.
|
||||||
|
*
|
||||||
|
* `verify()` performs the SMTP handshake and, where credentials are configured, an authentication
|
||||||
|
* exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every
|
||||||
|
* time an operator ran it would be its own incident.
|
||||||
|
*/
|
||||||
|
async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
|
||||||
|
const provider = createSmtpMailProvider(config);
|
||||||
|
try {
|
||||||
|
await provider.verify();
|
||||||
|
} finally {
|
||||||
|
await provider.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||||
composePermissions: composePlatformPermissionRegistry,
|
composePermissions: composePlatformPermissionRegistry,
|
||||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||||
@@ -141,6 +277,10 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
|||||||
discoverModules,
|
discoverModules,
|
||||||
discoverSchemas: discoverModuleSchemas,
|
discoverSchemas: discoverModuleSchemas,
|
||||||
planMigrations: planModuleMigrations,
|
planMigrations: planModuleMigrations,
|
||||||
|
probeMail: probeMailForRuntime,
|
||||||
|
probeStorage: probeStorageForRuntime,
|
||||||
|
readModuleConfiguration: readModuleConfigurationForRuntime,
|
||||||
|
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||||
runtimeSatisfies: isVersionCompatible,
|
runtimeSatisfies: isVersionCompatible,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
|||||||
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
|
||||||
|
* — how many of them have a compatible provider among the installed set.
|
||||||
|
*
|
||||||
|
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
|
||||||
|
* capability nobody provides, or whose provider moved outside its declared range, is simply not
|
||||||
|
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
|
||||||
|
* are derived from the same publication registry the API serves from, so this can never disagree
|
||||||
|
* with what the running platform would actually publish.
|
||||||
|
*
|
||||||
|
* Static only, like every other Doctor check: it reports what the *manifests* support and never
|
||||||
|
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
|
||||||
|
*/
|
||||||
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||||
|
const publication = createModuleUiPublicationRegistry(modules);
|
||||||
|
const compatible = new Set(
|
||||||
|
publication
|
||||||
|
.listContributions()
|
||||||
|
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||||
|
);
|
||||||
return modules.map((module) => {
|
return modules.map((module) => {
|
||||||
const id = module.manifest.id;
|
const id = module.manifest.id;
|
||||||
const version = module.manifest.version;
|
const version = module.manifest.version;
|
||||||
@@ -187,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
|
|||||||
) {
|
) {
|
||||||
throw new TypeError("Unsafe module diagnostic value.");
|
throw new TypeError("Unsafe module diagnostic value.");
|
||||||
}
|
}
|
||||||
|
const declared = module.contributions.length;
|
||||||
|
if (declared === 0) {
|
||||||
return `${id} ${version}`;
|
return `${id} ${version}`;
|
||||||
|
}
|
||||||
|
const resolved = module.contributions.filter((contribution) =>
|
||||||
|
compatible.has(`${id}\u0000${contribution.id}`),
|
||||||
|
).length;
|
||||||
|
return `${id} ${version} · ${declared} browser contribution${
|
||||||
|
declared === 1 ? "" : "s"
|
||||||
|
} (${resolved} with a compatible provider)`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +417,222 @@ function permissionCompositionFailure(error: unknown): DoctorCheck {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one module's operational state in a form that is safe by construction: only a module id, a
|
||||||
|
* semantic version, a fixed state word, a fixed reason code from a closed set, and — for a
|
||||||
|
* capability edge — a capability id. Identity values are re-validated before interpolation, exactly
|
||||||
|
* like `safeModuleDetails` and `safeMigrationReference` do, so a future drift bug cannot turn this
|
||||||
|
* into a disclosure channel. No filesystem path, package name, manifest path, or caught error can
|
||||||
|
* reach this line, because none of them is on the record it reads.
|
||||||
|
*/
|
||||||
|
function safeLifecycleDetail(entry: ModuleEligibility): string {
|
||||||
|
if (
|
||||||
|
!moduleIdSchema.safeParse(entry.moduleId).success ||
|
||||||
|
!semanticVersionSchema.safeParse(entry.version).success
|
||||||
|
) {
|
||||||
|
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||||
|
}
|
||||||
|
const identity = `${entry.moduleId} ${entry.version}`;
|
||||||
|
if (!entry.configured.enabled) {
|
||||||
|
return `${identity} · disabled${entry.configured.explicit ? "" : " (default)"}`;
|
||||||
|
}
|
||||||
|
if (!entry.blocked) {
|
||||||
|
return `${identity} · enabled`;
|
||||||
|
}
|
||||||
|
if (isStorageBlockedDetail(entry.blocked)) {
|
||||||
|
// A storage block names the **logical** bucket the module declared — never an endpoint, a
|
||||||
|
// credential, or the physical bucket the host maps it to. Re-validated against the same grammar
|
||||||
|
// the manifest accepted it under, for the same defense-in-depth reason as every other
|
||||||
|
// interpolation here.
|
||||||
|
if (!logicalBucketIdSchema.safeParse(entry.blocked.storageBucketId).success) {
|
||||||
|
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||||
|
}
|
||||||
|
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.storageBucketId}`;
|
||||||
|
}
|
||||||
|
if (isConfigurationBlockedDetail(entry.blocked)) {
|
||||||
|
// A configuration block names the offending **key** and never its value: a key is declared in a
|
||||||
|
// public manifest, while a value may be a credential or arbitrary operator-entered text. The key
|
||||||
|
// is re-validated against the same grammar the manifest accepted it under, for the same
|
||||||
|
// defense-in-depth reason every other interpolation here is.
|
||||||
|
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
|
||||||
|
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||||
|
}
|
||||||
|
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
|
||||||
|
}
|
||||||
|
const dependency = entry.blocked.dependency;
|
||||||
|
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
|
||||||
|
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||||
|
}
|
||||||
|
const target =
|
||||||
|
dependency.kind === "capability" &&
|
||||||
|
dependency.capabilityId !== undefined &&
|
||||||
|
capabilityIdSchema.safeParse(dependency.capabilityId).success
|
||||||
|
? `${dependency.capabilityId} (${dependency.moduleId})`
|
||||||
|
: dependency.moduleId;
|
||||||
|
return `${identity} · blocked · ${entry.blocked.reason} · ${target}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports what an operator most wants to know before anything else: which installed modules this
|
||||||
|
* deployment would actually run, and why any of them would not.
|
||||||
|
*
|
||||||
|
* Configuration lives in PostgreSQL, so this check is honestly skipped when `DATABASE_URL` is not
|
||||||
|
* configured rather than reporting a default that might not be what is stored. A **disabled** module
|
||||||
|
* is a `pass` with a detail line: it is a decision somebody made, not a problem. A **blocked** module
|
||||||
|
* is a `warning`: an enabled module that cannot run is an anomaly worth surfacing, and it is exactly
|
||||||
|
* the question "why is this module unavailable?" that would otherwise send an operator to the logs.
|
||||||
|
*/
|
||||||
|
function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorCheck {
|
||||||
|
const disabled = entries.filter((entry) => !entry.configured.enabled).length;
|
||||||
|
const blocked = entries.filter((entry) => entry.blocked !== undefined).length;
|
||||||
|
const eligible = entries.filter((entry) => entry.eligible).length;
|
||||||
|
const details = [
|
||||||
|
`${eligible} eligible · ${disabled} disabled · ${blocked} blocked`,
|
||||||
|
...entries.map(safeLifecycleDetail),
|
||||||
|
];
|
||||||
|
if (blocked > 0) {
|
||||||
|
return freezeCheck(
|
||||||
|
"modules.lifecycle",
|
||||||
|
"warning",
|
||||||
|
blocked === 1 ? "1 enabled module cannot run" : `${blocked} enabled modules cannot run`,
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports whether each installed module's configuration would actually let it run — and
|
||||||
|
* deliberately reports **nothing about the values themselves**.
|
||||||
|
*
|
||||||
|
* Not one configuration value is printed, secret or otherwise. A value is operator-entered text that
|
||||||
|
* may be an endpoint, a bucket name, or a credential that ended up in the wrong field; a *key* is
|
||||||
|
* declared in a public manifest and is safe. So a healthy module renders as a count, and an unhealthy
|
||||||
|
* one names the reason and the offending key. That is everything an operator needs to act, and
|
||||||
|
* nothing that turns `pnpm doctor` output — which people paste into chat — into a disclosure channel.
|
||||||
|
*/
|
||||||
|
function safeConfigurationDetail(entry: ModuleEligibility, declaredCount: number): string {
|
||||||
|
if (
|
||||||
|
!moduleIdSchema.safeParse(entry.moduleId).success ||
|
||||||
|
!semanticVersionSchema.safeParse(entry.version).success
|
||||||
|
) {
|
||||||
|
throw new TypeError("Unsafe module configuration diagnostic value.");
|
||||||
|
}
|
||||||
|
const identity = `${entry.moduleId} ${entry.version}`;
|
||||||
|
if (declaredCount === 0) {
|
||||||
|
return `${identity} · no configuration declared`;
|
||||||
|
}
|
||||||
|
const declared = `${declaredCount} ${plural(declaredCount, "key")} declared`;
|
||||||
|
if (entry.blocked && isConfigurationBlockedDetail(entry.blocked)) {
|
||||||
|
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
|
||||||
|
throw new TypeError("Unsafe module configuration diagnostic value.");
|
||||||
|
}
|
||||||
|
return `${identity} · ${declared} · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
|
||||||
|
}
|
||||||
|
if (!entry.configured.enabled) {
|
||||||
|
// Not evaluated: a disabled module's configuration is not resolved at all, and claiming it is
|
||||||
|
// valid would be asserting something nothing checked.
|
||||||
|
return `${identity} · ${declared} · not evaluated (disabled)`;
|
||||||
|
}
|
||||||
|
return `${identity} · ${declared} · valid`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configuration counterpart to `modules.lifecycle`: which installed modules could not run
|
||||||
|
* because of their own configuration, and which key is at fault.
|
||||||
|
*
|
||||||
|
* A module whose configuration is fine, or which declares none, is a `pass` line. A module blocked
|
||||||
|
* by configuration is a `warning`, for the same reason a dependency-blocked module is: an enabled
|
||||||
|
* module that cannot run is an anomaly an operator should see without reading logs.
|
||||||
|
*/
|
||||||
|
function moduleConfigurationCheck(
|
||||||
|
entries: readonly ModuleEligibility[],
|
||||||
|
declaredCounts: ReadonlyMap<string, number>,
|
||||||
|
): DoctorCheck {
|
||||||
|
const blockedByConfiguration = entries.filter(
|
||||||
|
(entry) => entry.blocked !== undefined && !("dependency" in entry.blocked),
|
||||||
|
).length;
|
||||||
|
const declaringModules = entries.filter(
|
||||||
|
(entry) => (declaredCounts.get(entry.moduleId) ?? 0) > 0,
|
||||||
|
).length;
|
||||||
|
const details = [
|
||||||
|
`${declaringModules} ${plural(declaringModules, "module")} declaring configuration · ${blockedByConfiguration} blocked`,
|
||||||
|
...entries.map((entry) =>
|
||||||
|
safeConfigurationDetail(entry, declaredCounts.get(entry.moduleId) ?? 0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (blockedByConfiguration > 0) {
|
||||||
|
return freezeCheck(
|
||||||
|
"modules.configuration",
|
||||||
|
"warning",
|
||||||
|
blockedByConfiguration === 1
|
||||||
|
? "1 module cannot run with its current configuration"
|
||||||
|
: `${blockedByConfiguration} modules cannot run with their current configuration`,
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return freezeCheck("modules.configuration", "pass", "Module configuration valid", details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports which installed modules require object storage, and where the host keeps each binding.
|
||||||
|
*
|
||||||
|
* Names the module, the **logical** bucket it declared, and the physical bucket the host maps it to
|
||||||
|
* — the last of which is safe and useful here precisely because Doctor is an operator tool: someone
|
||||||
|
* debugging a permissions problem needs to know which bucket to look at. It is still never reachable
|
||||||
|
* from module-facing code (ADR 0030). No endpoint, access key, or secret key appears in any form.
|
||||||
|
*/
|
||||||
|
function storageBindingsCheck(
|
||||||
|
modules: readonly DiscoveredModule[],
|
||||||
|
entries: readonly ModuleEligibility[],
|
||||||
|
config: StorageProviderConfig | undefined,
|
||||||
|
): DoctorCheck {
|
||||||
|
const requiring = modules.filter((module) => module.manifest.storage.buckets.length > 0);
|
||||||
|
if (requiring.length === 0) {
|
||||||
|
// An installation whose modules need no object storage is not unhealthy for lacking a provider.
|
||||||
|
return freezeCheck("object-storage.bindings", "pass", "No module requires object storage", [
|
||||||
|
"0 modules declare a logical storage bucket.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = entries.filter(
|
||||||
|
(entry) => entry.blocked !== undefined && isStorageBlockedDetail(entry.blocked),
|
||||||
|
);
|
||||||
|
const details = requiring.flatMap((module) => {
|
||||||
|
const moduleId = module.manifest.id;
|
||||||
|
if (!moduleIdSchema.safeParse(moduleId).success) {
|
||||||
|
throw new TypeError("Unsafe module storage diagnostic value.");
|
||||||
|
}
|
||||||
|
return module.manifest.storage.buckets.map((bucket) => {
|
||||||
|
if (!logicalBucketIdSchema.safeParse(bucket.id).success) {
|
||||||
|
throw new TypeError("Unsafe module storage diagnostic value.");
|
||||||
|
}
|
||||||
|
return config === undefined
|
||||||
|
? `${moduleId} · ${bucket.id} · unbound (no storage provider configured)`
|
||||||
|
: `${moduleId} · ${bucket.id} → ${physicalBucketName(config.bucketPrefix, moduleId, bucket.id)}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config === undefined) {
|
||||||
|
return freezeCheck(
|
||||||
|
"object-storage.bindings",
|
||||||
|
"failure",
|
||||||
|
blocked.length === 1
|
||||||
|
? "1 module requires object storage that is not configured"
|
||||||
|
: `${blocked.length} modules require object storage that is not configured`,
|
||||||
|
[
|
||||||
|
"Configure an S3-compatible Ekklesia storage provider (S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY).",
|
||||||
|
...details,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return freezeCheck(
|
||||||
|
"object-storage.bindings",
|
||||||
|
"pass",
|
||||||
|
`${details.length} ${plural(details.length, "storage binding")} resolved`,
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||||
const details =
|
const details =
|
||||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
error instanceof Error && error.name === "ModuleDiscoveryError"
|
||||||
@@ -373,6 +757,35 @@ function identitySchemaSummary(): string {
|
|||||||
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045).
|
||||||
|
*
|
||||||
|
* The same hostname on another port is the same site. Otherwise the two hostnames must be siblings:
|
||||||
|
* identical after their first label, with that parent itself holding at least one dot
|
||||||
|
* (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in
|
||||||
|
* both directions for unusual names, which is why its result is a warning and never a failure.
|
||||||
|
*/
|
||||||
|
function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean {
|
||||||
|
const dashboard = new URL(dashboardOrigin).hostname;
|
||||||
|
const api = new URL(apiOrigin).hostname;
|
||||||
|
if (dashboard === api) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// An IP address is a site of its own; two different ones never share one.
|
||||||
|
const isIpAddress = (hostname: string): boolean =>
|
||||||
|
hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname);
|
||||||
|
if (isIpAddress(dashboard) || isIpAddress(api)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1);
|
||||||
|
return (
|
||||||
|
dashboard.includes(".") &&
|
||||||
|
api.includes(".") &&
|
||||||
|
parent(dashboard) === parent(api) &&
|
||||||
|
parent(dashboard).includes(".")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
||||||
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
||||||
@@ -385,6 +798,7 @@ function identityConfigurationCheck(
|
|||||||
const hasAnyValue =
|
const hasAnyValue =
|
||||||
input?.secret !== undefined ||
|
input?.secret !== undefined ||
|
||||||
input?.baseUrl !== undefined ||
|
input?.baseUrl !== undefined ||
|
||||||
|
(input?.dashboardOrigin ?? "") !== "" ||
|
||||||
input?.publicSignupEnabled !== undefined ||
|
input?.publicSignupEnabled !== undefined ||
|
||||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||||
|
|
||||||
@@ -399,17 +813,43 @@ function identityConfigurationCheck(
|
|||||||
const config = createIdentityConfig(
|
const config = createIdentityConfig(
|
||||||
{
|
{
|
||||||
baseUrl: input?.baseUrl,
|
baseUrl: input?.baseUrl,
|
||||||
|
// Validated exactly as the API validates it at startup, so a Dashboard origin the API would
|
||||||
|
// refuse (untrusted, insecure, malformed) fails here first.
|
||||||
|
dashboardOrigin: input?.dashboardOrigin,
|
||||||
publicSignupEnabled: input?.publicSignupEnabled,
|
publicSignupEnabled: input?.publicSignupEnabled,
|
||||||
secret: input?.secret,
|
secret: input?.secret,
|
||||||
trustedOrigins: input?.trustedOrigins,
|
trustedOrigins: input?.trustedOrigins,
|
||||||
},
|
},
|
||||||
{ environment: input?.environment ?? "production" },
|
{ environment: input?.environment ?? "production" },
|
||||||
);
|
);
|
||||||
|
const signupDetail = config.publicSignupEnabled
|
||||||
|
? "Public signup is explicitly ENABLED."
|
||||||
|
: "Public signup is disabled (safe default).";
|
||||||
|
if (config.dashboardOrigin === config.baseUrl) {
|
||||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||||
schemaDetail,
|
schemaDetail,
|
||||||
config.publicSignupEnabled
|
signupDetail,
|
||||||
? "Public signup is explicitly ENABLED."
|
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||||
: "Public signup is disabled (safe default).",
|
]);
|
||||||
|
}
|
||||||
|
const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins).";
|
||||||
|
if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) {
|
||||||
|
return freezeCheck(
|
||||||
|
"identity.configuration",
|
||||||
|
"warning",
|
||||||
|
"Identity configuration valid; split origins may not share a site",
|
||||||
|
[
|
||||||
|
schemaDetail,
|
||||||
|
signupDetail,
|
||||||
|
topologyDetail,
|
||||||
|
"The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||||
|
schemaDetail,
|
||||||
|
signupDetail,
|
||||||
|
topologyDetail,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
@@ -508,6 +948,23 @@ export async function diagnoseEkklesia(
|
|||||||
options: DoctorOptions,
|
options: DoctorOptions,
|
||||||
dependencies: DoctorDependencies = productionDependencies,
|
dependencies: DoctorDependencies = productionDependencies,
|
||||||
): Promise<DoctorReport> {
|
): Promise<DoctorReport> {
|
||||||
|
// Resolved first, before module eligibility is decided: whether a provider is configured is an
|
||||||
|
// input to whether a module that declares a bucket can run, so validating it later would mean
|
||||||
|
// deciding eligibility against a question that had not been answered yet.
|
||||||
|
const hostEnvironment =
|
||||||
|
options.databaseEnvironment === "development" ? "development" : "production";
|
||||||
|
let storageConfig: StorageProviderConfig | undefined;
|
||||||
|
let storageConfigurationError: string | undefined;
|
||||||
|
try {
|
||||||
|
storageConfig = createStorageProviderConfig(options.storageConfiguration, {
|
||||||
|
environment: hostEnvironment,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// The sanitized code only — never the endpoint, the access key, or the secret key.
|
||||||
|
storageConfigurationError =
|
||||||
|
error instanceof StorageConfigError ? error.code : "STORAGE_CONFIGURATION_INVALID";
|
||||||
|
}
|
||||||
|
|
||||||
let runtimeCheck: DoctorCheck;
|
let runtimeCheck: DoctorCheck;
|
||||||
try {
|
try {
|
||||||
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
|
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
|
||||||
@@ -574,6 +1031,74 @@ export async function diagnoseEkklesia(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deliberately independent of permission composition, schema, and migration discovery: an
|
||||||
|
// operator asking "is this module running?" must still get an answer when an unrelated check
|
||||||
|
// failed. It needs only a successful discovery and a configured database.
|
||||||
|
let lifecycleCheck: DoctorCheck;
|
||||||
|
let configurationCheck: DoctorCheck;
|
||||||
|
// Hoisted so the storage-bindings check below reports the same eligibility this pass resolved,
|
||||||
|
// rather than resolving a second time and risking a different answer.
|
||||||
|
let eligibilityEntries: readonly ModuleEligibility[] = [];
|
||||||
|
const skippedConfiguration = (detail: string): DoctorCheck =>
|
||||||
|
freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]);
|
||||||
|
if (!discoveredModules) {
|
||||||
|
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||||
|
"Complete module discovery is required.",
|
||||||
|
]);
|
||||||
|
configurationCheck = skippedConfiguration("Complete module discovery is required.");
|
||||||
|
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
|
||||||
|
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||||
|
"DATABASE_URL is not configured.",
|
||||||
|
]);
|
||||||
|
configurationCheck = skippedConfiguration("DATABASE_URL is not configured.");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
||||||
|
environment: options.databaseEnvironment ?? "production",
|
||||||
|
});
|
||||||
|
const poolOptions = {
|
||||||
|
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
|
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
|
transport: lifecycleTransport,
|
||||||
|
};
|
||||||
|
// Both axes read and resolved in the one pass the platform itself uses, so Doctor can never
|
||||||
|
// report a module as runnable that startup would block, or the reverse.
|
||||||
|
// Sequential for the same reason `startApi` reads them sequentially: a deterministic failure
|
||||||
|
// beats two tiny queries racing.
|
||||||
|
const configuration = await dependencies.readModuleLifecycle(
|
||||||
|
options.databaseUrl,
|
||||||
|
poolOptions,
|
||||||
|
);
|
||||||
|
const settings = await dependencies.readModuleConfiguration(options.databaseUrl, poolOptions);
|
||||||
|
const resolution = resolveModuleEligibility(
|
||||||
|
discoveredModules,
|
||||||
|
configuration,
|
||||||
|
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
|
||||||
|
{ providerConfigured: storageConfig !== undefined },
|
||||||
|
);
|
||||||
|
eligibilityEntries = resolution.entries;
|
||||||
|
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
|
||||||
|
configurationCheck = moduleConfigurationCheck(
|
||||||
|
resolution.entries,
|
||||||
|
new Map(
|
||||||
|
discoveredModules.map(
|
||||||
|
(module) => [module.manifest.id, module.manifest.configuration.length] as const,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
|
||||||
|
"The stored module enablement configuration could not be read safely.",
|
||||||
|
]);
|
||||||
|
configurationCheck = freezeCheck(
|
||||||
|
"modules.configuration",
|
||||||
|
"failure",
|
||||||
|
"Module configuration unavailable",
|
||||||
|
["The stored module configuration could not be read safely."],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let schemaCheck: DoctorCheck;
|
let schemaCheck: DoctorCheck;
|
||||||
let migrationCheck: DoctorCheck;
|
let migrationCheck: DoctorCheck;
|
||||||
let migrations: ModuleMigrationAggregate | undefined;
|
let migrations: ModuleMigrationAggregate | undefined;
|
||||||
@@ -659,15 +1184,138 @@ export async function diagnoseEkklesia(
|
|||||||
options.databaseEnvironment,
|
options.databaseEnvironment,
|
||||||
);
|
);
|
||||||
|
|
||||||
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
// Two separate questions, deliberately. "Is a provider configured and reachable?" is
|
||||||
"Core storage integration has not been implemented yet.",
|
// infrastructure diagnostics; "can every module's declared bucket be bound?" is what decides
|
||||||
|
// whether modules run. Conflating them would let a momentary network blip read as a module
|
||||||
|
// problem (ADR 0030).
|
||||||
|
let storageCheck: DoctorCheck;
|
||||||
|
if (storageConfigurationError !== undefined) {
|
||||||
|
// A failure, unlike an unreachable provider: this is not a network condition an operator can
|
||||||
|
// wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is
|
||||||
|
// genuinely not ready, and saying so is what stops the operator from discovering it at boot.
|
||||||
|
storageCheck = freezeCheck(
|
||||||
|
"object-storage.integration",
|
||||||
|
"failure",
|
||||||
|
"Object storage configuration invalid",
|
||||||
|
[
|
||||||
|
`The object-storage settings were rejected: ${storageConfigurationError}.`,
|
||||||
|
"Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (!storageConfig) {
|
||||||
|
storageCheck = freezeCheck(
|
||||||
|
"object-storage.integration",
|
||||||
|
"skipped",
|
||||||
|
"Object storage not configured",
|
||||||
|
["No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage."],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await dependencies.probeStorage(storageConfig);
|
||||||
|
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
|
||||||
|
describeStorageProvider(storageConfig),
|
||||||
|
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a
|
||||||
|
// separate browser-facing origin is by definition something a browser reaches and this
|
||||||
|
// process may not. Reporting "reachable" without that caveat would let an operator conclude
|
||||||
|
// direct delivery works when only half of it was tested.
|
||||||
|
...(storageConfig.deliveryEndpoint === storageConfig.endpoint
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
|
} catch {
|
||||||
|
// A warning, never a failure: the provider is configured, and a module that is already
|
||||||
|
// running keeps running through a transient outage. Reporting this as `not-ready` would make
|
||||||
|
// the platform look broken because a network hiccup happened while Doctor ran.
|
||||||
|
storageCheck = freezeCheck(
|
||||||
|
"object-storage.integration",
|
||||||
|
"warning",
|
||||||
|
"Object storage unreachable",
|
||||||
|
[
|
||||||
|
describeStorageProvider(storageConfig),
|
||||||
|
"The configured storage service did not respond. Modules already running are unaffected.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageBindings = discoveredModules
|
||||||
|
? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig)
|
||||||
|
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
|
||||||
|
"Complete module discovery is required.",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not
|
||||||
|
// answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in
|
||||||
|
// the platform sends email yet, so an installation without SMTP is complete rather than degraded
|
||||||
|
// (ADR 0037).
|
||||||
|
let mailCheck: DoctorCheck;
|
||||||
|
let mailConfig: MailProviderConfig | undefined;
|
||||||
|
let mailConfigurationError: string | undefined;
|
||||||
|
try {
|
||||||
|
mailConfig = createMailProviderConfig(options.mailConfiguration, {
|
||||||
|
environment: hostEnvironment,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// The sanitized code only — never the host, the username, or the password.
|
||||||
|
mailConfigurationError =
|
||||||
|
error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mailConfigurationError !== undefined) {
|
||||||
|
mailCheck = freezeCheck(
|
||||||
|
"outbound-email.configuration",
|
||||||
|
"failure",
|
||||||
|
"Outbound email configuration invalid",
|
||||||
|
[
|
||||||
|
`The SMTP settings were rejected: ${mailConfigurationError}.`,
|
||||||
|
"Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (!mailConfig) {
|
||||||
|
mailCheck = freezeCheck(
|
||||||
|
"outbound-email.configuration",
|
||||||
|
"skipped",
|
||||||
|
"Outbound email not configured",
|
||||||
|
[
|
||||||
|
"No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await dependencies.probeMail(mailConfig);
|
||||||
|
mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [
|
||||||
|
describeMailProvider(mailConfig),
|
||||||
|
// Said plainly: this connected and, where credentials are set, authenticated. It did not
|
||||||
|
// send anything, so it proves the relay accepts this deployment — not that a message would
|
||||||
|
// reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization
|
||||||
|
// policy and cannot be established from here.
|
||||||
|
"Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.",
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// A warning, never a failure, exactly as for storage: the configuration is valid and nothing
|
||||||
|
// in the platform depends on mail yet, so a relay that is briefly unreachable — or one that
|
||||||
|
// declines a bare connectivity check — must not make the deployment read as broken.
|
||||||
|
mailCheck = freezeCheck(
|
||||||
|
"outbound-email.configuration",
|
||||||
|
"warning",
|
||||||
|
"Outbound email unreachable",
|
||||||
|
[
|
||||||
|
describeMailProvider(mailConfig),
|
||||||
|
"The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return publishReport([
|
return publishReport([
|
||||||
runtimeCheck,
|
runtimeCheck,
|
||||||
moduleCheck,
|
moduleCheck,
|
||||||
dependencyCheck,
|
dependencyCheck,
|
||||||
permissionsCheck,
|
permissionsCheck,
|
||||||
|
lifecycleCheck,
|
||||||
|
configurationCheck,
|
||||||
schemaCheck,
|
schemaCheck,
|
||||||
migrationCheck,
|
migrationCheck,
|
||||||
transportCheck,
|
transportCheck,
|
||||||
@@ -676,5 +1324,7 @@ export async function diagnoseEkklesia(
|
|||||||
identityCheck,
|
identityCheck,
|
||||||
clientAddressDoctorCheck,
|
clientAddressDoctorCheck,
|
||||||
storageCheck,
|
storageCheck,
|
||||||
|
storageBindings,
|
||||||
|
mailCheck,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { ModuleSecretSource } from "@ekklesia/contracts";
|
||||||
|
import type { DatabaseTransportPolicyInput } from "../database/index.js";
|
||||||
|
import type { ClientAddressConfigInput } from "../identity/index.js";
|
||||||
|
import type { MailProviderConfigInput } from "../mail/index.js";
|
||||||
|
import { createEnvironmentModuleSecretSource } from "../modules/index.js";
|
||||||
|
import type { StorageProviderConfigInput } from "../storage/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deployment's environment, passed **as an argument**.
|
||||||
|
*
|
||||||
|
* `process.env` satisfies this, but nothing here reads it: the repository rule that only
|
||||||
|
* executables touch the process environment is preserved exactly as
|
||||||
|
* `createEnvironmentModuleSecretSource` preserves it, and it is what makes every variable this
|
||||||
|
* module understands testable without mutating global state.
|
||||||
|
*/
|
||||||
|
export type HostEnvironment = Readonly<Record<string, string | undefined>>;
|
||||||
|
|
||||||
|
export type HostEnvironmentName = "development" | "production";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity settings as they appear in the environment — strings, unvalidated.
|
||||||
|
*
|
||||||
|
* Typed as `string | undefined` rather than `unknown` so this one object can be handed to both
|
||||||
|
* `createIdentityConfig` (which validates it) and Doctor's `identityConfiguration` (which validates
|
||||||
|
* it and reports the sanitized code) without either side re-deriving it.
|
||||||
|
*/
|
||||||
|
export interface HostIdentityEnvironmentInput {
|
||||||
|
readonly baseUrl: string | undefined;
|
||||||
|
/** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is
|
||||||
|
* served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */
|
||||||
|
readonly dashboardOrigin: string | undefined;
|
||||||
|
readonly publicSignupEnabled: string | undefined;
|
||||||
|
readonly secret: string | undefined;
|
||||||
|
readonly trustedOrigins: readonly string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every host infrastructure input an Ekklesia executable resolves from its environment.
|
||||||
|
*
|
||||||
|
* Deliberately **unvalidated**: each consumer runs the same Core validator over these values, but
|
||||||
|
* they do so at different moments and want different failure behaviour. `startApi` wants a
|
||||||
|
* malformed value to abort startup; Doctor wants it reported as a sanitized code in a check. If this
|
||||||
|
* type were pre-validated, Doctor could never report a configuration error — the load would have
|
||||||
|
* thrown before a report existed.
|
||||||
|
*
|
||||||
|
* The API listener's own `host`/`port` are deliberately absent: they describe the API process, not
|
||||||
|
* the host infrastructure a diagnostic evaluates, and Doctor has no use for them.
|
||||||
|
*/
|
||||||
|
export interface HostConfiguration {
|
||||||
|
/** `"development"` only when `NODE_ENV` is exactly that string, so an unset or misspelled value
|
||||||
|
* validates as production and never silently relaxes a security rule. */
|
||||||
|
readonly environment: HostEnvironmentName;
|
||||||
|
/** `undefined` when no mode is set at all, which selects direct mode. */
|
||||||
|
readonly clientAddress: ClientAddressConfigInput | undefined;
|
||||||
|
readonly databaseTransport: DatabaseTransportPolicyInput;
|
||||||
|
readonly databaseUrl: string | undefined;
|
||||||
|
readonly identity: HostIdentityEnvironmentInput;
|
||||||
|
readonly mail: MailProviderConfigInput;
|
||||||
|
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
|
||||||
|
* declared keys — there is no arbitrary variable lookup. */
|
||||||
|
readonly moduleSecrets: ModuleSecretSource;
|
||||||
|
readonly storage: StorageProviderConfigInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitList(value: string | undefined): readonly string[] | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Omitted entirely when no mode is set: reading `header`/`trustedProxies` only when `mode` is
|
||||||
|
* actually present keeps an operator from half-configuring proxy mode by setting one of three
|
||||||
|
* variables. */
|
||||||
|
function readClientAddress(environment: HostEnvironment): ClientAddressConfigInput | undefined {
|
||||||
|
const mode = environment.EKKLESIA_CLIENT_ADDRESS_MODE;
|
||||||
|
if (mode === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
header: environment.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
||||||
|
mode,
|
||||||
|
trustedProxies: splitList(environment.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place any Ekklesia executable turns a deployment environment into host infrastructure
|
||||||
|
* configuration.
|
||||||
|
*
|
||||||
|
* It exists because the alternative was demonstrably wrong: `apps/api` and `pnpm doctor` each read
|
||||||
|
* their own subset of the same variables, and the subsets drifted. Doctor never read `S3_*` at all,
|
||||||
|
* so a correctly configured installation was told its object storage was "not configured" and every
|
||||||
|
* module requiring a bucket was reported blocked — while `startApi`, from the identical environment,
|
||||||
|
* bound those buckets and ran the modules. A diagnostic that disagrees with the runtime it
|
||||||
|
* diagnoses is worse than no diagnostic.
|
||||||
|
*
|
||||||
|
* So there is now exactly one mapping from variable names to typed inputs, and both executables call
|
||||||
|
* it. Validation stays where it already lives — `createIdentityConfig`,
|
||||||
|
* `createDatabaseTransportPolicy`, `createStorageProviderConfig`, `createMailProviderConfig` — and
|
||||||
|
* is deliberately *not* performed here, so each caller keeps its own failure behaviour.
|
||||||
|
*
|
||||||
|
* Nothing is read from `process.env`, and nothing here logs, prints, or serializes: the returned
|
||||||
|
* object carries the identity secret, both storage credentials, and the SMTP password.
|
||||||
|
*/
|
||||||
|
export function loadHostConfiguration(environment: HostEnvironment): HostConfiguration {
|
||||||
|
return Object.freeze({
|
||||||
|
clientAddress: readClientAddress(environment),
|
||||||
|
databaseTransport: {
|
||||||
|
...(environment.EKKLESIA_DATABASE_CA === undefined
|
||||||
|
? {}
|
||||||
|
: { certificateAuthority: environment.EKKLESIA_DATABASE_CA }),
|
||||||
|
...(environment.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||||
|
? {}
|
||||||
|
: { mode: environment.EKKLESIA_DATABASE_TLS_MODE }),
|
||||||
|
},
|
||||||
|
databaseUrl: environment.DATABASE_URL,
|
||||||
|
environment: environment.NODE_ENV === "development" ? "development" : "production",
|
||||||
|
identity: {
|
||||||
|
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
|
||||||
|
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
|
||||||
|
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
||||||
|
secret: environment.EKKLESIA_IDENTITY_SECRET,
|
||||||
|
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
|
||||||
|
},
|
||||||
|
// Names follow the platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a
|
||||||
|
// provider's. Absent host means this installation sends no outbound email, which is a complete
|
||||||
|
// deployment (ADR 0037); username and password are optional as a pair, because a relay
|
||||||
|
// authorized by source IP has neither.
|
||||||
|
mail: {
|
||||||
|
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
|
||||||
|
fromName: environment.EKKLESIA_SMTP_FROM_NAME,
|
||||||
|
// The name this client announces in `EHLO`. Optional, and unset is the ordinary case: it
|
||||||
|
// matters only where the machine's own hostname is not a fully qualified name the relay will
|
||||||
|
// accept.
|
||||||
|
heloName: environment.EKKLESIA_SMTP_HELO_NAME,
|
||||||
|
host: environment.EKKLESIA_SMTP_HOST,
|
||||||
|
password: environment.EKKLESIA_SMTP_PASSWORD,
|
||||||
|
port: environment.EKKLESIA_SMTP_PORT,
|
||||||
|
security: environment.EKKLESIA_SMTP_SECURITY,
|
||||||
|
username: environment.EKKLESIA_SMTP_USERNAME,
|
||||||
|
},
|
||||||
|
moduleSecrets: createEnvironmentModuleSecretSource(environment),
|
||||||
|
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
|
||||||
|
// endpoint means this installation has no object storage, which is a complete deployment
|
||||||
|
// (ADR 0030).
|
||||||
|
storage: {
|
||||||
|
accessKey: environment.S3_ACCESS_KEY,
|
||||||
|
bucketPrefix: environment.S3_BUCKET_PREFIX,
|
||||||
|
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
|
||||||
|
// storage by is not a name a browser could resolve.
|
||||||
|
deliveryEndpoint: environment.S3_DELIVERY_ENDPOINT,
|
||||||
|
endpoint: environment.S3_ENDPOINT,
|
||||||
|
forcePathStyle: environment.S3_FORCE_PATH_STYLE,
|
||||||
|
region: environment.S3_REGION,
|
||||||
|
secretKey: environment.S3_SECRET_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
type HostConfiguration,
|
||||||
|
type HostEnvironment,
|
||||||
|
type HostEnvironmentName,
|
||||||
|
type HostIdentityEnvironmentInput,
|
||||||
|
loadHostConfiguration,
|
||||||
|
} from "./configuration.js";
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||||
|
import { identityAdapterSchema } from "./schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a first credential identity **through Better Auth's own sign-up path**, for the
|
||||||
|
* operator-invoked initial-owner bootstrap and nothing else.
|
||||||
|
*
|
||||||
|
* ## Why this is not `createIdentityRuntime`
|
||||||
|
*
|
||||||
|
* The server runtime deliberately refuses to create accounts: `disableSignUp` is derived from
|
||||||
|
* `IdentityConfig.publicSignupEnabled`, which production cannot enable at all
|
||||||
|
* (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). That is correct and is not relaxed here — calling
|
||||||
|
* `auth.api.signUpEmail` on the server's own instance is refused with
|
||||||
|
* `EMAIL_PASSWORD_SIGN_UP_DISABLED`, which was verified against the pinned release rather than
|
||||||
|
* assumed. So a fresh production installation has no way to create the one identity it needs.
|
||||||
|
*
|
||||||
|
* This is that way, and its safety comes from *where it can be invoked*, not from a weaker policy:
|
||||||
|
* it is an in-process library call reachable only by an operator who already holds the database
|
||||||
|
* credentials, it serves no HTTP route, and it is mounted nowhere. Nothing in `apps/api` imports
|
||||||
|
* it. Public signup remains disabled in every environment.
|
||||||
|
*
|
||||||
|
* ## Why the library path rather than writing rows
|
||||||
|
*
|
||||||
|
* Hashing, salting, email normalization, the `user`/`account` pair, and the 8–128 password bounds
|
||||||
|
* are all Better Auth's, applied exactly as they are for a browser sign-up. Hand-writing a user row
|
||||||
|
* and a password hash would be a second, divergent identity implementation — and the failure mode
|
||||||
|
* is an account that exists but cannot sign in.
|
||||||
|
*
|
||||||
|
* ## Prerequisites this deliberately does not have
|
||||||
|
*
|
||||||
|
* No `baseURL`, no trusted origins, and no configured identity secret. All three were tested
|
||||||
|
* against the pinned release: sign-up succeeds without them, and the resulting account signs in
|
||||||
|
* through a *separately constructed* instance carrying a different secret — password hashes are
|
||||||
|
* salted per account and independent of the secret. That matters operationally, because it means an
|
||||||
|
* operator can create the first owner on a freshly provisioned server before DNS, TLS, or the
|
||||||
|
* public origin exist. The ephemeral secret below exists only so the instance is explicitly
|
||||||
|
* configured rather than silently defaulted; this instance never mints a session, so nothing is
|
||||||
|
* ever signed with it and it is discarded when the process exits.
|
||||||
|
*/
|
||||||
|
export type BootstrapIdentityErrorCode =
|
||||||
|
| "BOOTSTRAP_IDENTITY_CREATION_FAILED"
|
||||||
|
| "BOOTSTRAP_IDENTITY_EMAIL_INVALID"
|
||||||
|
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG"
|
||||||
|
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT";
|
||||||
|
|
||||||
|
const bootstrapIdentityMessages: Readonly<Record<BootstrapIdentityErrorCode, string>> =
|
||||||
|
Object.freeze({
|
||||||
|
BOOTSTRAP_IDENTITY_CREATION_FAILED: "The initial identity could not be created.",
|
||||||
|
BOOTSTRAP_IDENTITY_EMAIL_INVALID: "The email address is not a valid address.",
|
||||||
|
BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||||
|
BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT:
|
||||||
|
"The password is shorter than the identity system allows.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Never carries the password, the email, a hash, a Better Auth response body, or a cause. */
|
||||||
|
export class BootstrapIdentityError extends Error {
|
||||||
|
readonly code: BootstrapIdentityErrorCode;
|
||||||
|
|
||||||
|
constructor(code: BootstrapIdentityErrorCode) {
|
||||||
|
super(bootstrapIdentityMessages[code]);
|
||||||
|
this.name = "BootstrapIdentityError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapIdentityInput {
|
||||||
|
readonly email: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates one credential identity. Resolving says only that the identity system reported success —
|
||||||
|
* never that a row exists, which the caller must confirm for itself (see `createInitialPlatformOwner`). */
|
||||||
|
export type BootstrapIdentityCreator = (input: BootstrapIdentityInput) => Promise<void>;
|
||||||
|
|
||||||
|
/** Better Auth reports failures as an `APIError` carrying a `body.code`. Only the codes that are
|
||||||
|
* genuinely the operator's to act on are distinguished; everything else collapses into the generic
|
||||||
|
* creation failure so no library internal, response body, or cause is ever surfaced. */
|
||||||
|
function translateBootstrapIdentityError(error: unknown): BootstrapIdentityError {
|
||||||
|
const code = (error as { body?: { code?: unknown } } | null)?.body?.code;
|
||||||
|
if (code === "PASSWORD_TOO_SHORT") {
|
||||||
|
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT");
|
||||||
|
}
|
||||||
|
if (code === "PASSWORD_TOO_LONG") {
|
||||||
|
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG");
|
||||||
|
}
|
||||||
|
if (code === "VALIDATION_ERROR") {
|
||||||
|
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_EMAIL_INVALID");
|
||||||
|
}
|
||||||
|
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_CREATION_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBootstrapIdentityCreator(
|
||||||
|
db: IdentityDrizzleDatabase,
|
||||||
|
): BootstrapIdentityCreator {
|
||||||
|
const auth = betterAuth({
|
||||||
|
database: drizzleAdapter(db, { provider: "pg", schema: identityAdapterSchema }),
|
||||||
|
emailAndPassword: {
|
||||||
|
// No session is minted here. The operator signs in through the ordinary browser transport
|
||||||
|
// afterwards, which is also what proves the account really works.
|
||||||
|
autoSignIn: false,
|
||||||
|
// Enabled on *this* instance only. See the note above: this object serves no HTTP route.
|
||||||
|
disableSignUp: false,
|
||||||
|
enabled: true,
|
||||||
|
// The identical bounds `createIdentityRuntime` pins, so the CLI can never accept a password
|
||||||
|
// the browser would later reject, or reject one it would accept.
|
||||||
|
maxPasswordLength: 128,
|
||||||
|
minPasswordLength: 8,
|
||||||
|
},
|
||||||
|
logger: {
|
||||||
|
// Better Auth's own log call sites interpolate raw input and exceptions; during account
|
||||||
|
// creation that input includes a password. Nothing it wants to log is ever emitted.
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
secret: randomBytes(32).toString("base64url"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return async (input: BootstrapIdentityInput): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await auth.api.signUpEmail({
|
||||||
|
body: { email: input.email, name: input.name, password: input.password },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw translateBootstrapIdentityError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
|
|||||||
| "BASE_URL_INVALID"
|
| "BASE_URL_INVALID"
|
||||||
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
||||||
| "BASE_URL_MISSING"
|
| "BASE_URL_MISSING"
|
||||||
|
| "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"
|
||||||
|
| "DASHBOARD_ORIGIN_INVALID"
|
||||||
|
| "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"
|
||||||
|
| "DASHBOARD_ORIGIN_UNTRUSTED"
|
||||||
| "PUBLIC_SIGNUP_ENABLED_INVALID"
|
| "PUBLIC_SIGNUP_ENABLED_INVALID"
|
||||||
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
|
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
|
||||||
| "SECRET_LOW_ENTROPY"
|
| "SECRET_LOW_ENTROPY"
|
||||||
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
|
|||||||
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
||||||
"The identity base URL must not resolve to a loopback host in production.",
|
"The identity base URL must not resolve to a loopback host in production.",
|
||||||
BASE_URL_MISSING: "The identity base URL is required.",
|
BASE_URL_MISSING: "The identity base URL is required.",
|
||||||
|
DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.",
|
||||||
|
DASHBOARD_ORIGIN_INVALID:
|
||||||
|
"The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.",
|
||||||
|
DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION:
|
||||||
|
"The Dashboard origin must not resolve to a loopback host in production.",
|
||||||
|
DASHBOARD_ORIGIN_UNTRUSTED:
|
||||||
|
"The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.",
|
||||||
PUBLIC_SIGNUP_ENABLED_INVALID:
|
PUBLIC_SIGNUP_ENABLED_INVALID:
|
||||||
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
|
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
|
||||||
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
||||||
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
|
|||||||
|
|
||||||
export interface IdentityConfig {
|
export interface IdentityConfig {
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
|
/**
|
||||||
|
* The browser origin an emailed verification or password-reset link sends a person to.
|
||||||
|
*
|
||||||
|
* Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API
|
||||||
|
* are served from one origin. It exists as its own value because they are not the same *concept* —
|
||||||
|
* `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and
|
||||||
|
* a deployment may separate them (an API at `https://api.example.org`, the Dashboard at
|
||||||
|
* `https://dash.example.org`). It is deliberately **not** the public church website origin: this
|
||||||
|
* is the authenticated Dashboard host and nothing else.
|
||||||
|
*
|
||||||
|
* Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so
|
||||||
|
* a misconfiguration fails at startup rather than producing emailed links every recipient is
|
||||||
|
* refused at.
|
||||||
|
*/
|
||||||
|
readonly dashboardOrigin: string;
|
||||||
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
|
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
|
||||||
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
||||||
* loosely parsed. Development and test composition may enable it explicitly; production
|
* loosely parsed. Development and test composition may enable it explicitly; production
|
||||||
@@ -176,6 +202,8 @@ export interface IdentityConfig {
|
|||||||
|
|
||||||
export interface IdentityConfigInput {
|
export interface IdentityConfigInput {
|
||||||
readonly baseUrl?: unknown;
|
readonly baseUrl?: unknown;
|
||||||
|
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
|
||||||
|
readonly dashboardOrigin?: unknown;
|
||||||
readonly publicSignupEnabled?: unknown;
|
readonly publicSignupEnabled?: unknown;
|
||||||
readonly secret?: unknown;
|
readonly secret?: unknown;
|
||||||
readonly trustedOrigins?: unknown;
|
readonly trustedOrigins?: unknown;
|
||||||
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
|
|||||||
return url.origin;
|
return url.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the origin an emailed link points a browser at.
|
||||||
|
*
|
||||||
|
* Unset falls back to the identity base URL — the same-origin production shape — rather than
|
||||||
|
* failing, because a deployment that has not separated its Dashboard from its API has nothing to
|
||||||
|
* configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it
|
||||||
|
* must be an origin Better Auth already trusts, because Better Auth validates the emailed callback
|
||||||
|
* against `trustedOrigins` before redirecting. Checking it here converts a broken link every
|
||||||
|
* recipient would hit into a startup failure the operator sees once.
|
||||||
|
*/
|
||||||
|
function validateDashboardOrigin(
|
||||||
|
dashboardOrigin: unknown,
|
||||||
|
baseUrl: string,
|
||||||
|
trustedOrigins: readonly string[],
|
||||||
|
environment: IdentityEnvironment,
|
||||||
|
): string {
|
||||||
|
if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
const url = parseCanonicalHttpOrigin(dashboardOrigin);
|
||||||
|
if (!url) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID");
|
||||||
|
}
|
||||||
|
if (environment === "production") {
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the
|
||||||
|
// base URL's origin to its trusted set (`getTrustedOrigins`, pinned release).
|
||||||
|
if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED");
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
||||||
if (typeof origin !== "string" || origin.length === 0) {
|
if (typeof origin !== "string" || origin.length === 0) {
|
||||||
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
||||||
@@ -355,7 +422,19 @@ export function createIdentityConfig(
|
|||||||
const secret = validateSecret(input.secret);
|
const secret = validateSecret(input.secret);
|
||||||
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
||||||
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
||||||
|
const dashboardOrigin = validateDashboardOrigin(
|
||||||
|
input.dashboardOrigin,
|
||||||
|
baseUrl,
|
||||||
|
trustedOrigins,
|
||||||
|
environment,
|
||||||
|
);
|
||||||
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
||||||
|
|
||||||
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
|
return Object.freeze({
|
||||||
|
baseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
publicSignupEnabled,
|
||||||
|
secret,
|
||||||
|
trustedOrigins,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
export * from "./bootstrap-identity.js";
|
||||||
export * from "./client-address.js";
|
export * from "./client-address.js";
|
||||||
export * from "./config.js";
|
export * from "./config.js";
|
||||||
export * from "./database.js";
|
export * from "./database.js";
|
||||||
|
export * from "./notifications.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { MailError, type MailProvider, validateMailMessage } from "../mail/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity feature's outbound messages, and the seam between Better Auth's callbacks and the
|
||||||
|
* host's mail transport (ADR 0038).
|
||||||
|
*
|
||||||
|
* ## What this owns and what it deliberately does not
|
||||||
|
*
|
||||||
|
* It owns the recipient, the subject, and the two bodies. It does **not** know the SMTP host, the
|
||||||
|
* port, the TLS mode, a credential, or the sender: those are host configuration held by the mail
|
||||||
|
* provider, and there is no field on the message contract through which a caller could choose any
|
||||||
|
* of them (ADR 0037). Swapping SMTP for a provider HTTP API would not change a line of this file.
|
||||||
|
*
|
||||||
|
* It also does not own tokens. Better Auth mints, persists (for reset), expires, and consumes them;
|
||||||
|
* this receives one already-minted token and puts it in a link. There is no Ekklesia token table,
|
||||||
|
* no Ekklesia token hashing, and no Ekklesia expiry clock — a second implementation of any of those
|
||||||
|
* would be a second thing to get wrong.
|
||||||
|
*
|
||||||
|
* ## Why the link is rebuilt rather than used as given
|
||||||
|
*
|
||||||
|
* Better Auth hands these callbacks a prebuilt `url`, and that URL embeds whatever `callbackURL` or
|
||||||
|
* `redirectTo` the *requesting client* supplied. Better Auth does validate those against its trusted
|
||||||
|
* origins, and that validation is sound — but an emailed link is the one artifact a person is most
|
||||||
|
* likely to click without looking, so its destination is derived here from validated configuration
|
||||||
|
* alone and from nothing a request could influence. The token is the only value taken from Better
|
||||||
|
* Auth. No request parameter can steer where a recipient lands, independently of whether the origin
|
||||||
|
* check behaves as expected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What could not be delivered, for an operator — never who it was for, and never the link. */
|
||||||
|
export type IdentityNotificationKind = "password-reset" | "email-verification";
|
||||||
|
|
||||||
|
export interface IdentityNotificationFailure {
|
||||||
|
readonly kind: IdentityNotificationKind;
|
||||||
|
/** A sanitized `MailError` code, or `MAIL_SEND_FAILED` for anything unrecognized. */
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a delivery failure is reported.
|
||||||
|
*
|
||||||
|
* It exists because these callbacks must **never throw**. Better Auth's own
|
||||||
|
* `/send-verification-email` rethrows whatever the callback threw, and a thrown error there turns
|
||||||
|
* "this address has an unverified account" into a distinguishable response — an account-enumeration
|
||||||
|
* oracle that appears the moment SMTP breaks. So failure is swallowed at this boundary and surfaced
|
||||||
|
* out of band instead, where no HTTP response shape depends on it.
|
||||||
|
*
|
||||||
|
* The default implementation writes one fixed line per failure. It carries the flow and a sanitized
|
||||||
|
* code and nothing else: no address, no token, no URL, no provider text.
|
||||||
|
*/
|
||||||
|
export type IdentityNotificationReporter = (failure: IdentityNotificationFailure) => void;
|
||||||
|
|
||||||
|
export interface IdentityNotificationRecipient {
|
||||||
|
readonly email: string;
|
||||||
|
readonly name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIdentityNotificationsOptions {
|
||||||
|
/**
|
||||||
|
* The host mail provider, or `undefined` when this installation has none.
|
||||||
|
*
|
||||||
|
* `undefined` is a complete deployment (ADR 0037): the platform still runs, the initial owner
|
||||||
|
* still signs in, and only verification and recovery are unavailable — reported as
|
||||||
|
* `MAIL_NOT_CONFIGURED` through the reporter, never as a different HTTP response.
|
||||||
|
*/
|
||||||
|
readonly mail: MailProvider | undefined;
|
||||||
|
/** Where the `/api/auth` transport lives — the origin Better Auth's own endpoints are on. */
|
||||||
|
readonly authBaseUrl: string;
|
||||||
|
/** Where a human's browser lands. Validated to be a trusted origin (`./config.js`). */
|
||||||
|
readonly dashboardOrigin: string;
|
||||||
|
/** Defaults to a single fixed sanitized console line. */
|
||||||
|
readonly report?: IdentityNotificationReporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityNotifications {
|
||||||
|
/** Shaped for Better Auth's `emailVerification.sendVerificationEmail`. Never throws. */
|
||||||
|
readonly sendVerificationEmail: (data: {
|
||||||
|
readonly user: IdentityNotificationRecipient;
|
||||||
|
readonly token: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
/** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */
|
||||||
|
readonly sendResetPassword: (data: {
|
||||||
|
readonly user: IdentityNotificationRecipient;
|
||||||
|
readonly token: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two query values the Dashboard bundle reads to decide which host-owned auth screen to show.
|
||||||
|
*
|
||||||
|
* They are query parameters on the Dashboard's root path rather than distinct paths deliberately:
|
||||||
|
* the shell is a static single-page bundle, and a deep path would require every deployment's static
|
||||||
|
* host to rewrite unknown paths to `index.html` before an emailed link worked at all. A query
|
||||||
|
* parameter works on any static host, including the repository's own development server.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_AUTH_LANDING_PARAMETER = "ekklesia_auth";
|
||||||
|
export const DASHBOARD_VERIFY_EMAIL_LANDING = "verify-email";
|
||||||
|
export const DASHBOARD_RESET_PASSWORD_LANDING = "reset-password";
|
||||||
|
|
||||||
|
function landingUrl(dashboardOrigin: string, landing: string): string {
|
||||||
|
const url = new URL(dashboardOrigin);
|
||||||
|
url.searchParams.set(DASHBOARD_AUTH_LANDING_PARAMETER, landing);
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Better Auth's own verification URL, rebuilt from configuration.
|
||||||
|
*
|
||||||
|
* The shape is the pinned release's protocol exactly — `GET {authBase}/api/auth/verify-email?
|
||||||
|
* token=...&callbackURL=...` — so Better Auth remains the only thing that inspects, expires, or acts
|
||||||
|
* on the token. Only the destination is ours.
|
||||||
|
*/
|
||||||
|
function verificationUrl(options: CreateIdentityNotificationsOptions, token: string): string {
|
||||||
|
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_VERIFY_EMAIL_LANDING);
|
||||||
|
return `${options.authBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}&callbackURL=${encodeURIComponent(callback)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Better Auth's reset callback, rebuilt the same way. It validates the token, then redirects the
|
||||||
|
* browser to the Dashboard landing with the token attached. */
|
||||||
|
function resetUrl(options: CreateIdentityNotificationsOptions, token: string): string {
|
||||||
|
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_RESET_PASSWORD_LANDING);
|
||||||
|
return `${options.authBaseUrl}/api/auth/reset-password/${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}?callbackURL=${encodeURIComponent(callback)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escapes the four characters that could otherwise close an element or attribute in the HTML body.
|
||||||
|
* Only the link and the greeting name are interpolated, and the name comes from the user record. */
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same rule `validateMailMessage` applies, expressed the same way it expresses it: a code-point
|
||||||
|
* scan rather than a regular expression, because a regex over a control-character range is itself
|
||||||
|
* something a linter (rightly) objects to.
|
||||||
|
*/
|
||||||
|
function hasControlCharacter(value: string): boolean {
|
||||||
|
for (const character of value) {
|
||||||
|
const codePoint = character.codePointAt(0);
|
||||||
|
if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A greeting that is safe to place in a message body.
|
||||||
|
*
|
||||||
|
* A stored display name is not trusted to be free of control characters — the message validator
|
||||||
|
* would reject the whole message for one stray carriage return, which would turn a cosmetic problem
|
||||||
|
* into an undeliverable password reset. So an unusable name simply falls back to no greeting.
|
||||||
|
*/
|
||||||
|
function greeting(name: string | undefined): string {
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
return "Hello,";
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.length > 64 || hasControlCharacter(trimmed)) {
|
||||||
|
return "Hello,";
|
||||||
|
}
|
||||||
|
return `Hello ${trimmed},`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPIRY_SENTENCE = "This link expires in one hour.";
|
||||||
|
const UNEXPECTED_SENTENCE =
|
||||||
|
"If you did not expect this message, you can ignore it and nothing will change.";
|
||||||
|
|
||||||
|
interface MessageBodies {
|
||||||
|
readonly subject: string;
|
||||||
|
readonly text: string;
|
||||||
|
readonly html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verificationBodies(name: string | undefined, url: string): MessageBodies {
|
||||||
|
const lead = greeting(name);
|
||||||
|
return {
|
||||||
|
subject: "Verify your Ekklesia email address",
|
||||||
|
html: [
|
||||||
|
`<p>${escapeHtml(lead)}</p>`,
|
||||||
|
"<p>Confirm this email address so it can be used to sign in to Ekklesia.</p>",
|
||||||
|
`<p><a href="${escapeHtml(url)}">Verify this email address</a></p>`,
|
||||||
|
`<p>${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}</p>`,
|
||||||
|
].join("\n"),
|
||||||
|
text: [
|
||||||
|
lead,
|
||||||
|
"",
|
||||||
|
"Confirm this email address so it can be used to sign in to Ekklesia:",
|
||||||
|
"",
|
||||||
|
url,
|
||||||
|
"",
|
||||||
|
EXPIRY_SENTENCE,
|
||||||
|
UNEXPECTED_SENTENCE,
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBodies(name: string | undefined, url: string): MessageBodies {
|
||||||
|
const lead = greeting(name);
|
||||||
|
const closing = `${UNEXPECTED_SENTENCE} Your current password stays in place until a new one is chosen.`;
|
||||||
|
return {
|
||||||
|
subject: "Reset your Ekklesia password",
|
||||||
|
html: [
|
||||||
|
`<p>${escapeHtml(lead)}</p>`,
|
||||||
|
"<p>Someone asked to reset the password for this Ekklesia account.</p>",
|
||||||
|
`<p><a href="${escapeHtml(url)}">Choose a new password</a></p>`,
|
||||||
|
`<p>${EXPIRY_SENTENCE} ${escapeHtml(closing)}</p>`,
|
||||||
|
].join("\n"),
|
||||||
|
text: [
|
||||||
|
lead,
|
||||||
|
"",
|
||||||
|
"Someone asked to reset the password for this Ekklesia account. Choose a new one here:",
|
||||||
|
"",
|
||||||
|
url,
|
||||||
|
"",
|
||||||
|
EXPIRY_SENTENCE,
|
||||||
|
closing,
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReporter(failure: IdentityNotificationFailure): void {
|
||||||
|
// One fixed line. The flow and a sanitized code are exactly what an operator needs in order to go
|
||||||
|
// look at `pnpm doctor`; the address, the token, and the link are exactly what must never be here.
|
||||||
|
console.error(`[identity] ${failure.kind} email was not delivered: ${failure.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureReason(error: unknown): string {
|
||||||
|
return error instanceof MailError ? error.code : "MAIL_SEND_FAILED";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the two Better Auth callbacks.
|
||||||
|
*
|
||||||
|
* Neither rejects. A delivery failure is reported through `report` and the callback resolves, so the
|
||||||
|
* HTTP response an unauthenticated caller sees is identical whether the address exists, whether it
|
||||||
|
* was already verified, and whether the relay was reachable.
|
||||||
|
*/
|
||||||
|
export function createIdentityNotifications(
|
||||||
|
options: CreateIdentityNotificationsOptions,
|
||||||
|
): IdentityNotifications {
|
||||||
|
const report = options.report ?? defaultReporter;
|
||||||
|
|
||||||
|
const deliver = async (
|
||||||
|
kind: IdentityNotificationKind,
|
||||||
|
recipient: IdentityNotificationRecipient,
|
||||||
|
bodies: MessageBodies,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!options.mail) {
|
||||||
|
report({ kind, reason: "MAIL_NOT_CONFIGURED" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.mail.send(
|
||||||
|
validateMailMessage({
|
||||||
|
html: bodies.html,
|
||||||
|
subject: bodies.subject,
|
||||||
|
text: bodies.text,
|
||||||
|
to: recipient.email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
report({ kind, reason: failureReason(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifications: IdentityNotifications = {
|
||||||
|
sendResetPassword: async (data) => {
|
||||||
|
await deliver(
|
||||||
|
"password-reset",
|
||||||
|
data.user,
|
||||||
|
resetBodies(data.user.name, resetUrl(options, data.token)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sendVerificationEmail: async (data) => {
|
||||||
|
await deliver(
|
||||||
|
"email-verification",
|
||||||
|
data.user,
|
||||||
|
verificationBodies(data.user.name, verificationUrl(options, data.token)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze(notifications);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies";
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import type { IdentityConfig } from "./config.js";
|
import type { IdentityConfig } from "./config.js";
|
||||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||||
|
import type { IdentityNotifications } from "./notifications.js";
|
||||||
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
||||||
|
|
||||||
export type IdentityRuntimeErrorCode =
|
export type IdentityRuntimeErrorCode =
|
||||||
@@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
|
|||||||
*/
|
*/
|
||||||
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
|
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One hour for both, matching the pinned release's own defaults but stated rather than inherited.
|
||||||
|
*
|
||||||
|
* Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough
|
||||||
|
* that someone who reads their mail after lunch is not sent back to the start. The message bodies
|
||||||
|
* state the same hour, so the two can be checked against each other.
|
||||||
|
*/
|
||||||
|
const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which
|
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which
|
||||||
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
|
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
|
||||||
@@ -82,10 +93,29 @@ export interface CreateIdentityRuntimeOptions {
|
|||||||
readonly config: IdentityConfig;
|
readonly config: IdentityConfig;
|
||||||
readonly db: IdentityDrizzleDatabase;
|
readonly db: IdentityDrizzleDatabase;
|
||||||
/**
|
/**
|
||||||
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test
|
* How verification and password-reset messages are delivered.
|
||||||
* composition that issues many such requests quickly. Rate limiting itself is always enabled;
|
*
|
||||||
* production composition must leave this unset so Better Auth's own strict built-in default
|
* Injected rather than constructed here, for the same reason the database is: this file composes
|
||||||
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified.
|
* Better Auth and must not decide what a message says or how it travels. Omitting it is a
|
||||||
|
* complete, valid deployment — email verification and password recovery are then configured
|
||||||
|
* exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while
|
||||||
|
* every HTTP response keeps its usual shape (ADR 0038).
|
||||||
|
*
|
||||||
|
* Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An
|
||||||
|
* installation with no mail cannot verify a new account, which is why the initial platform owner
|
||||||
|
* is created already verified by a trusted operator command rather than by an email round trip.
|
||||||
|
*/
|
||||||
|
readonly notifications?: IdentityNotifications;
|
||||||
|
/**
|
||||||
|
* A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats
|
||||||
|
* more strictly than its global default, used only by test composition that issues many such
|
||||||
|
* requests quickly. Rate limiting itself is always enabled; production composition must leave this
|
||||||
|
* unset so Better Auth's own built-in defaults apply unmodified — 3 requests / 10 seconds on
|
||||||
|
* sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification
|
||||||
|
* resends, which are the two paths an attacker could otherwise use to send mail repeatedly.
|
||||||
|
*
|
||||||
|
* That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single
|
||||||
|
* deployment and is deliberately **not** claimed to be a distributed one.
|
||||||
*/
|
*/
|
||||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
}
|
}
|
||||||
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
|
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
|
||||||
// in the pinned release): an implicit default can change silently on a future Better Auth
|
// in the pinned release): an implicit default can change silently on a future Better Auth
|
||||||
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary.
|
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint
|
||||||
|
// enforces the identical pair from the same configuration, so there is deliberately no
|
||||||
|
// separate reset-time password policy.
|
||||||
maxPasswordLength: 128,
|
maxPasswordLength: 128,
|
||||||
minPasswordLength: 8,
|
minPasswordLength: 8,
|
||||||
|
/**
|
||||||
|
* A person may not hold a session until their address is verified (ADR 0038).
|
||||||
|
*
|
||||||
|
* In the pinned release this is enforced *after* the password is checked and *before* any
|
||||||
|
* session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set.
|
||||||
|
* That is the whole reason no "unverified principal" exists anywhere in this platform — a
|
||||||
|
* correct password on an unverified account produces no session at all, so there is no
|
||||||
|
* half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to
|
||||||
|
* describe only anonymous, authenticated human, and device.
|
||||||
|
*/
|
||||||
|
requireEmailVerification: true,
|
||||||
|
/** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the
|
||||||
|
* password bounds are pinned. */
|
||||||
|
resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS,
|
||||||
|
/**
|
||||||
|
* Every existing session for that account is deleted when its password is reset.
|
||||||
|
*
|
||||||
|
* Password reset is the recovery path someone uses precisely when they suspect their
|
||||||
|
* credential is compromised, and leaving the attacker's session alive would make the recovery
|
||||||
|
* cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned
|
||||||
|
* release), not a Core reach into the session table.
|
||||||
|
*
|
||||||
|
* It deletes *all* of that human's sessions, including the browser that performed the reset —
|
||||||
|
* so the completion state returns to the sign-in form rather than pretending to be signed in.
|
||||||
|
* Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no
|
||||||
|
* password, and are entirely unaffected (ADR 0036).
|
||||||
|
*/
|
||||||
|
revokeSessionsOnPasswordReset: true,
|
||||||
|
...(options.notifications
|
||||||
|
? { sendResetPassword: options.notifications.sendResetPassword }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Verification is configured identically whether or not mail is deliverable, so the policy an
|
||||||
|
* installation enforces never depends on the state of its relay.
|
||||||
|
*/
|
||||||
|
emailVerification: {
|
||||||
|
/**
|
||||||
|
* No session is minted from following an emailed link.
|
||||||
|
*
|
||||||
|
* A verification URL travels through mail infrastructure and is routinely fetched by things
|
||||||
|
* that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning
|
||||||
|
* that GET into a logged-in browser session would hand a session to whoever, or whatever,
|
||||||
|
* dereferenced the link. Verification therefore only flips the flag, and the person signs in
|
||||||
|
* afterwards through the ordinary form.
|
||||||
|
*/
|
||||||
|
autoSignInAfterVerification: false,
|
||||||
|
/** Pinned rather than left on the release's `|| 3600` fallback. */
|
||||||
|
expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS,
|
||||||
|
/**
|
||||||
|
* A sign-in attempt against an unverified account re-sends the verification message.
|
||||||
|
*
|
||||||
|
* This is the resend path that costs nothing to secure: reaching it requires the correct
|
||||||
|
* password, so it reveals nothing an attacker did not already know, and it means the common
|
||||||
|
* case — the first link expired — is handled by the thing the person was already trying to do.
|
||||||
|
* The standalone `/send-verification-email` endpoint remains available for the case where they
|
||||||
|
* would rather not retype a password, and is enumeration-safe on its own.
|
||||||
|
*/
|
||||||
|
sendOnSignIn: true,
|
||||||
|
/**
|
||||||
|
* Pinned rather than left on the release's "follow `requireEmailVerification`" fallback.
|
||||||
|
*
|
||||||
|
* It reaches nothing in production, where signup is refused outright — but stating it means a
|
||||||
|
* development or test deployment that does enable signup behaves the way this platform
|
||||||
|
* intends, rather than the way an inherited default happens to.
|
||||||
|
*/
|
||||||
|
sendOnSignUp: true,
|
||||||
|
...(options.notifications
|
||||||
|
? { sendVerificationEmail: options.notifications.sendVerificationEmail }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
logger: {
|
logger: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
|
|||||||
...(rateLimitOverride
|
...(rateLimitOverride
|
||||||
? {
|
? {
|
||||||
customRules: {
|
customRules: {
|
||||||
|
// The four paths Better Auth applies a stricter-than-global default to. Production
|
||||||
|
// composition never sets this override, so those defaults — 3 requests / 10 seconds
|
||||||
|
// on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an
|
||||||
|
// email — remain in force there, unmodified.
|
||||||
|
"/request-password-reset": {
|
||||||
|
max: rateLimitOverride.max ?? 100,
|
||||||
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
},
|
||||||
|
"/send-verification-email": {
|
||||||
|
max: rateLimitOverride.max ?? 100,
|
||||||
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
},
|
||||||
"/sign-in/email": {
|
"/sign-in/email": {
|
||||||
max: rateLimitOverride.max ?? 100,
|
max: rateLimitOverride.max ?? 100,
|
||||||
window: rateLimitOverride.window ?? 10,
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import {
|
||||||
|
isValidMailAddress,
|
||||||
|
isValidMailDisplayName,
|
||||||
|
MAIL_MAX_DISPLAY_NAME_LENGTH,
|
||||||
|
} from "./message.js";
|
||||||
|
|
||||||
|
export type MailConfigErrorCode =
|
||||||
|
| "MAIL_FROM_ADDRESS_INVALID"
|
||||||
|
| "MAIL_FROM_ADDRESS_MISSING"
|
||||||
|
| "MAIL_FROM_NAME_INVALID"
|
||||||
|
| "MAIL_HELO_NAME_INVALID"
|
||||||
|
| "MAIL_HOST_INVALID"
|
||||||
|
| "MAIL_PASSWORD_WITHOUT_USERNAME"
|
||||||
|
| "MAIL_PORT_INVALID"
|
||||||
|
| "MAIL_SECURITY_INVALID"
|
||||||
|
| "MAIL_SECURITY_INSECURE_IN_PRODUCTION"
|
||||||
|
| "MAIL_USERNAME_WITHOUT_PASSWORD";
|
||||||
|
|
||||||
|
const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object.freeze({
|
||||||
|
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
|
||||||
|
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
|
||||||
|
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
|
||||||
|
MAIL_HELO_NAME_INVALID:
|
||||||
|
"The SMTP HELO name must be a fully qualified domain name, with no scheme, port, brackets, or spaces.",
|
||||||
|
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
|
||||||
|
MAIL_PASSWORD_WITHOUT_USERNAME:
|
||||||
|
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
|
||||||
|
MAIL_PORT_INVALID: "The SMTP port must be an integer between 1 and 65535.",
|
||||||
|
MAIL_SECURITY_INSECURE_IN_PRODUCTION:
|
||||||
|
"A production SMTP transport must use implicit TLS or STARTTLS; plaintext is development-only.",
|
||||||
|
MAIL_SECURITY_INVALID:
|
||||||
|
'The SMTP security mode must be exactly "implicit-tls", "starttls", or "plaintext".',
|
||||||
|
MAIL_USERNAME_WITHOUT_PASSWORD:
|
||||||
|
"An SMTP username requires a password; supply both or neither for an unauthenticated relay.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never carries the host, port, username, password, or sender — only a fixed code and message.
|
||||||
|
* Matching `StorageConfigError` exactly, and for the same sharper reason: one of the values this
|
||||||
|
* validates is a credential.
|
||||||
|
*/
|
||||||
|
export class MailConfigError extends Error {
|
||||||
|
readonly code: MailConfigErrorCode;
|
||||||
|
|
||||||
|
constructor(code: MailConfigErrorCode) {
|
||||||
|
super(mailConfigMessages[code]);
|
||||||
|
this.name = "MailConfigError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MailEnvironment = "development" | "production";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the connection is protected, stated explicitly rather than inferred.
|
||||||
|
*
|
||||||
|
* A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear
|
||||||
|
* and hope STARTTLS is offered", and a server that quietly stops advertising STARTTLS — or an
|
||||||
|
* attacker who strips it — turns that into a plaintext session carrying a password, with nothing in
|
||||||
|
* the configuration that changed. Naming the mode makes the downgrade impossible to reach by
|
||||||
|
* accident: `starttls` **requires** the upgrade and fails the send if it is unavailable.
|
||||||
|
*
|
||||||
|
* - `implicit-tls` — TLS from the first byte, conventionally port 465.
|
||||||
|
* - `starttls` — plaintext connect, then a *mandatory* upgrade, conventionally 587 (or 25 on a
|
||||||
|
* relay). This is what Google Workspace SMTP Relay and most modern providers expect.
|
||||||
|
* - `plaintext` — no TLS at all. Development and a trusted local relay only; production refuses it.
|
||||||
|
*/
|
||||||
|
export const MAIL_SECURITY_MODES = ["implicit-tls", "starttls", "plaintext"] as const;
|
||||||
|
|
||||||
|
export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
|
||||||
|
|
||||||
|
export interface MailProviderConfigInput {
|
||||||
|
readonly host?: string | undefined;
|
||||||
|
readonly heloName?: string | undefined;
|
||||||
|
readonly port?: string | number | undefined;
|
||||||
|
readonly security?: string | undefined;
|
||||||
|
readonly username?: string | undefined;
|
||||||
|
readonly password?: string | undefined;
|
||||||
|
readonly fromAddress?: string | undefined;
|
||||||
|
readonly fromName?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The validated host mail configuration.
|
||||||
|
*
|
||||||
|
* **No feature or module ever sees this.** It reaches Core's provider and nothing else; the
|
||||||
|
* `MailProvider` seam speaks only in validated messages, and there is no accessor anywhere that
|
||||||
|
* could return the host, the port, or the password (ADR 0037).
|
||||||
|
*/
|
||||||
|
export interface MailProviderConfig {
|
||||||
|
readonly host: string;
|
||||||
|
/**
|
||||||
|
* The name this client identifies itself as in the SMTP `EHLO` command, when the deployment sets
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* This exists because of a real failure, and the failure is generic rather than tied to any one
|
||||||
|
* provider. An SMTP client must greet the server with its own identity, and when nothing is
|
||||||
|
* configured Nodemailer derives it from the machine's hostname — falling back to the address
|
||||||
|
* literal `[127.0.0.1]` whenever that hostname is not fully qualified, which is the normal state
|
||||||
|
* of a container or a VM nobody has named. Some relays refuse that greeting outright, closing the
|
||||||
|
* connection with a `421` before any message is offered, which surfaces here as a bare
|
||||||
|
* `MAIL_PROVIDER_UNAVAILABLE` and looks indistinguishable from the relay being down.
|
||||||
|
*
|
||||||
|
* Absent means Nodemailer's own derivation is used, unchanged. It is deliberately not defaulted to
|
||||||
|
* the sender's domain: a host whose machine hostname *is* a valid FQDN may already be greeting
|
||||||
|
* correctly — some relays check it against the connecting address's reverse DNS — and silently
|
||||||
|
* replacing a working identity would trade one deployment's bug for another's.
|
||||||
|
*/
|
||||||
|
readonly heloName?: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly security: MailSecurityMode;
|
||||||
|
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
|
||||||
|
* Workspace arrangement — has neither. */
|
||||||
|
readonly username?: string;
|
||||||
|
readonly password?: string;
|
||||||
|
readonly fromAddress: string;
|
||||||
|
readonly fromName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conventional defaults per mode, so an operator who names a mode does not also have to remember a
|
||||||
|
* port number. Any port may still be set explicitly. */
|
||||||
|
const DEFAULT_PORTS: Readonly<Record<MailSecurityMode, number>> = Object.freeze({
|
||||||
|
"implicit-tls": 465,
|
||||||
|
plaintext: 25,
|
||||||
|
starttls: 587,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A bare hostname or IP literal: no scheme, no port, no path, no credentials. Anything richer is a
|
||||||
|
* connection string, and a connection string is where a password ends up in a log. */
|
||||||
|
const HOST_PATTERN =
|
||||||
|
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/iu;
|
||||||
|
const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/u;
|
||||||
|
|
||||||
|
function validateHost(host: string): string {
|
||||||
|
const trimmed = host.trim();
|
||||||
|
if (trimmed.length === 0 || (!HOST_PATTERN.test(trimmed) && !IPV4_PATTERN.test(trimmed))) {
|
||||||
|
throw new MailConfigError("MAIL_HOST_INVALID");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fully qualified domain name, and deliberately nothing else.
|
||||||
|
*
|
||||||
|
* Stricter than `HOST_PATTERN` on purpose, in two ways. It requires **at least one dot**, because a
|
||||||
|
* single unqualified label is precisely the input Nodemailer already refuses to use — configuring
|
||||||
|
* one would reproduce the failure this setting exists to fix. And it requires the final label to
|
||||||
|
* begin with a letter, which rejects a bare IPv4 address: `EHLO 192.0.2.1` is not valid SMTP syntax,
|
||||||
|
* and the bracketed address-literal form that *is* valid is the greeting the affected relays reject.
|
||||||
|
*
|
||||||
|
* Everything the requirement rules out falls out of the anchors and the character class rather than
|
||||||
|
* from a list of special cases: a scheme, a port, a path, whitespace, brackets, a comma, a
|
||||||
|
* carriage return or line feed, and any other prose all fail to match.
|
||||||
|
*/
|
||||||
|
const HELO_NAME_PATTERN =
|
||||||
|
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
|
||||||
|
|
||||||
|
function validateHeloName(heloName: string): string {
|
||||||
|
if (!HELO_NAME_PATTERN.test(heloName)) {
|
||||||
|
throw new MailConfigError("MAIL_HELO_NAME_INVALID");
|
||||||
|
}
|
||||||
|
return heloName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
|
||||||
|
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
|
||||||
|
return DEFAULT_PORTS[security];
|
||||||
|
}
|
||||||
|
const parsed = typeof port === "number" ? port : Number(port.trim());
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||||
|
throw new MailConfigError("MAIL_PORT_INVALID");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSecurity(
|
||||||
|
security: string | undefined,
|
||||||
|
environment: MailEnvironment,
|
||||||
|
): MailSecurityMode {
|
||||||
|
// Defaulting to STARTTLS rather than to whatever the port implies: an omitted mode must never be
|
||||||
|
// the one that sends a password in the clear.
|
||||||
|
const mode = security === undefined || security.trim() === "" ? "starttls" : security.trim();
|
||||||
|
if (!(MAIL_SECURITY_MODES as readonly string[]).includes(mode)) {
|
||||||
|
throw new MailConfigError("MAIL_SECURITY_INVALID");
|
||||||
|
}
|
||||||
|
if (environment === "production" && mode === "plaintext") {
|
||||||
|
throw new MailConfigError("MAIL_SECURITY_INSECURE_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
return mode as MailSecurityMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the host's outbound email configuration, or reports that this installation has none.
|
||||||
|
*
|
||||||
|
* **Outbound email is optional.** An installation with no `host` returns `undefined`, and that is a
|
||||||
|
* complete, valid deployment — the platform starts, every existing feature works, and only a future
|
||||||
|
* feature that actually needs to send is affected, which it discovers as a structured
|
||||||
|
* `MAIL_NOT_CONFIGURED` rather than a crash (ADR 0037).
|
||||||
|
*
|
||||||
|
* Credentials are validated as a **pair**. Many SMTP examples assume a username and password, but a
|
||||||
|
* relay authorized by source IP — which is how Google Workspace SMTP Relay is commonly deployed —
|
||||||
|
* has neither, so requiring them would make the intended production arrangement unconfigurable.
|
||||||
|
* Half a pair is always a mistake, though: it means someone set one variable and expected
|
||||||
|
* authentication, so it fails loudly instead of silently connecting anonymously.
|
||||||
|
*/
|
||||||
|
export function createMailProviderConfig(
|
||||||
|
input: MailProviderConfigInput | undefined,
|
||||||
|
options: { readonly environment?: MailEnvironment } = {},
|
||||||
|
): MailProviderConfig | undefined {
|
||||||
|
const host = input?.host?.trim();
|
||||||
|
if (host === undefined || host === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = options.environment ?? "production";
|
||||||
|
const security = validateSecurity(input?.security, environment);
|
||||||
|
const validatedHost = validateHost(host);
|
||||||
|
const port = validatePort(input?.port, security);
|
||||||
|
|
||||||
|
// Validated exactly as supplied, with no trimming. Every other value here tolerates surrounding
|
||||||
|
// whitespace as a copy-paste artefact; this one must not, because a stray character in the value
|
||||||
|
// goes straight onto the wire in a protocol command. It is refused rather than repaired, the same
|
||||||
|
// rule an address follows.
|
||||||
|
const heloNameInput = input?.heloName;
|
||||||
|
const heloName =
|
||||||
|
heloNameInput === undefined || heloNameInput === ""
|
||||||
|
? undefined
|
||||||
|
: validateHeloName(heloNameInput);
|
||||||
|
|
||||||
|
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
|
||||||
|
const password = input?.password === "" ? undefined : input?.password;
|
||||||
|
if (username !== undefined && password === undefined) {
|
||||||
|
throw new MailConfigError("MAIL_USERNAME_WITHOUT_PASSWORD");
|
||||||
|
}
|
||||||
|
if (password !== undefined && username === undefined) {
|
||||||
|
throw new MailConfigError("MAIL_PASSWORD_WITHOUT_USERNAME");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromAddress = input?.fromAddress?.trim();
|
||||||
|
if (fromAddress === undefined || fromAddress === "") {
|
||||||
|
throw new MailConfigError("MAIL_FROM_ADDRESS_MISSING");
|
||||||
|
}
|
||||||
|
if (!isValidMailAddress(fromAddress)) {
|
||||||
|
throw new MailConfigError("MAIL_FROM_ADDRESS_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromName = input?.fromName?.trim() === "" ? undefined : input?.fromName?.trim();
|
||||||
|
if (fromName !== undefined && !isValidMailDisplayName(fromName)) {
|
||||||
|
throw new MailConfigError("MAIL_FROM_NAME_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
fromAddress,
|
||||||
|
host: validatedHost,
|
||||||
|
port,
|
||||||
|
security,
|
||||||
|
...(heloName === undefined ? {} : { heloName }),
|
||||||
|
...(fromName === undefined ? {} : { fromName }),
|
||||||
|
...(username === undefined || password === undefined ? {} : { password, username }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-line operator-facing description that is safe to print anywhere — Doctor output, a log line,
|
||||||
|
* a support transcript.
|
||||||
|
*
|
||||||
|
* It names the host, port, security mode, and sender, and states **whether** authentication is
|
||||||
|
* configured without ever naming the username or password. `describeStorageProvider` sets the same
|
||||||
|
* precedent.
|
||||||
|
*/
|
||||||
|
export function describeMailProvider(config: MailProviderConfig): string {
|
||||||
|
const sender =
|
||||||
|
config.fromName === undefined
|
||||||
|
? config.fromAddress
|
||||||
|
: `${config.fromName} <${config.fromAddress}>`;
|
||||||
|
// The HELO name is host infrastructure, not a credential: it is the identity this client announces
|
||||||
|
// in the clear on every connection, so printing it costs nothing and is exactly what an operator
|
||||||
|
// needs when a relay has refused the greeting.
|
||||||
|
const helo = config.heloName === undefined ? "" : ` · HELO ${config.heloName}`;
|
||||||
|
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
|
||||||
|
config.username === undefined ? "unauthenticated relay" : "authenticated"
|
||||||
|
}${helo}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./config.js";
|
||||||
|
export * from "./message.js";
|
||||||
|
export * from "./provider.js";
|
||||||
|
export * from "./smtp-provider.js";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user