Compare commits
89
Commits
1285c90618
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d593c10f1d | ||
|
|
06e55cebd5 | ||
|
|
f3cd5a4152 | ||
|
|
3a26ef49ad | ||
|
|
3ca6b2cb32 | ||
|
|
75324af045 | ||
|
|
f445a83ea5 | ||
|
|
a130eb78ea | ||
|
|
8127cc87b4 | ||
|
|
2969ed4992 | ||
|
|
14ac46bd3d | ||
|
|
bb7a9eb351 | ||
|
|
20d5128bad | ||
|
|
ca3f37d5f9 | ||
|
|
e36485bf99 | ||
|
|
c444ff570b | ||
|
|
07835b7b56 | ||
|
|
e03d38fcab | ||
|
|
b14613decd | ||
|
|
3563549e86 | ||
|
|
450d65116f | ||
|
|
ea28f8aa2e | ||
|
|
3581ad7c8e | ||
|
|
2b4f0afb72 | ||
|
|
a835215eb8 | ||
|
|
fa3021e49b | ||
|
|
ba32499456 | ||
|
|
80189c8ae2 | ||
|
|
3ce4fe03fe | ||
|
|
c8fa13dd6a | ||
|
|
c131543abf | ||
|
|
a8c7c50b4d | ||
|
|
91aa9c2930 | ||
|
|
2e0fe059c3 | ||
|
|
be5ed7853d | ||
|
|
fc6c8045c4 | ||
|
|
fcc531a39c | ||
|
|
7ff063b87a | ||
|
|
9a3b8dab8f | ||
|
|
af4a8009fb | ||
|
|
b9ca0d560d | ||
|
|
527239f6de | ||
|
|
baa689b2a7 | ||
|
|
fdaad58848 | ||
|
|
7729c99e24 | ||
|
|
db67cf7700 | ||
|
|
bd34c9c714 | ||
|
|
ac5b2b7f6c | ||
|
|
616c1d4834 | ||
|
|
7a76ff6f26 | ||
|
|
a02deb9433 | ||
|
|
82cbeef437 | ||
|
|
3f9b5e3cb0 | ||
|
|
ff159a04dc | ||
|
|
44e676f0df | ||
|
|
5d75d910fd | ||
|
|
1e5866504e | ||
|
|
f9c1108527 | ||
|
|
8d651c427d | ||
|
|
b5aa4371b6 | ||
|
|
44ba259f58 | ||
|
|
94d26cffa0 | ||
|
|
288c088377 | ||
|
|
27088fc054 | ||
|
|
a2db774b8c | ||
|
|
cda841f8c7 | ||
|
|
07a3c61083 | ||
|
|
f7621a599f | ||
|
|
51f4075fd6 | ||
|
|
6912680113 | ||
|
|
74e8840f0d | ||
|
|
2983789779 | ||
|
|
490b85b07c | ||
|
|
800ecfdb92 | ||
|
|
4a34a8203c | ||
|
|
5c589d8fec | ||
|
|
532ce3803b | ||
|
|
6b6b77c105 | ||
|
|
7a0f624fc3 | ||
|
|
3d6c85b0f8 | ||
|
|
1161f5a076 | ||
|
|
d3c6bd73d3 | ||
|
|
cafd513019 | ||
|
|
5e09330001 | ||
|
|
c38b572778 | ||
|
|
d1b9efd4d2 | ||
|
|
0e308eb9ad | ||
|
|
9c9ede0418 | ||
|
|
300f98c29d |
+97
-1
@@ -1,14 +1,110 @@
|
|||||||
# Local development only. Replace every credential outside a developer workstation.
|
# Local development only. Replace every credential outside a developer workstation.
|
||||||
|
#
|
||||||
|
# `pnpm doctor` loads this file's uncommitted `.env` counterpart and evaluates exactly the host
|
||||||
|
# infrastructure configuration `pnpm api:start` would use, so the readiness report reflects the real
|
||||||
|
# deployment without the API process running.
|
||||||
|
|
||||||
|
# Required for a local workstation, and only ever correct there. Anything else — including an
|
||||||
|
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
|
||||||
|
# (EKKLESIA_DATABASE_TLS_MODE defaults to verify-full) and rejects loopback/http identity origins.
|
||||||
|
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
POSTGRES_DB=ekklesia
|
POSTGRES_DB=ekklesia
|
||||||
POSTGRES_USER=ekklesia
|
POSTGRES_USER=ekklesia
|
||||||
POSTGRES_PASSWORD=ekklesia-development-only
|
POSTGRES_PASSWORD=ekklesia-development-only
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
|
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
|
||||||
|
|
||||||
|
# Ekklesia's object-storage provider (ADR 0030). Absent S3_ENDPOINT means this installation has no
|
||||||
|
# object storage, which is a complete deployment: only a module that declares a logical bucket in its
|
||||||
|
# manifest is affected, and it is reported blocked rather than breaking anything. Production requires
|
||||||
|
# https, exactly as the database transport requires verified TLS.
|
||||||
|
#
|
||||||
|
# These are host infrastructure. No module ever sees them: a module names a logical bucket and the
|
||||||
|
# host resolves the binding.
|
||||||
S3_ACCESS_KEY=ekklesia-development
|
S3_ACCESS_KEY=ekklesia-development
|
||||||
S3_SECRET_KEY=ekklesia-development-secret
|
S3_SECRET_KEY=ekklesia-development-secret
|
||||||
S3_BUCKET=ekklesia-development
|
|
||||||
S3_ENDPOINT=http://127.0.0.1:8333
|
S3_ENDPOINT=http://127.0.0.1:8333
|
||||||
S3_PORT=8333
|
S3_PORT=8333
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_FORCE_PATH_STYLE=true
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
# The origin a *browser* reaches object storage on, used only when signing temporary read URLs for
|
||||||
|
# direct delivery (ADR 0031). Leave it unset whenever S3_ENDPOINT is itself reachable from a browser,
|
||||||
|
# which is the case for this development stack: Compose publishes the service on loopback, so the
|
||||||
|
# endpoint above serves both roles and direct delivery works with no extra configuration.
|
||||||
|
#
|
||||||
|
# Set it when Core reaches storage by a name a browser cannot resolve — a Compose service name such
|
||||||
|
# as http://object-storage:8333, for instance — and browsers reach it through a public origin:
|
||||||
|
# S3_DELIVERY_ENDPOINT=https://storage.example.org
|
||||||
|
# It must be the origin the browser actually requests. An S3-compatible signature covers the request
|
||||||
|
# Host, so a URL signed for one origin cannot be rewritten to another afterwards; a reverse proxy in
|
||||||
|
# front of storage must therefore pass the original Host header through unchanged. The scheme is not
|
||||||
|
# signed, so terminating TLS at that proxy and forwarding plaintext to the service is fine.
|
||||||
|
# S3_DELIVERY_ENDPOINT=
|
||||||
|
# Namespaces every physical bucket this installation owns, so several Ekklesia-compatible deployments
|
||||||
|
# can share one S3-compatible service. Optional; defaults to "ekklesia".
|
||||||
|
S3_BUCKET_PREFIX=ekklesia
|
||||||
|
# Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider —
|
||||||
|
# physical buckets are derived from a module's manifest declaration, not configured here.
|
||||||
|
S3_BUCKET=ekklesia-development
|
||||||
|
|
||||||
|
# Ekklesia's outbound email transport (ADR 0037). Absent EKKLESIA_SMTP_HOST means this installation
|
||||||
|
# sends no email, which is a complete deployment: nothing in the platform sends any yet, and a
|
||||||
|
# future feature that needs to is told so rather than crashing.
|
||||||
|
#
|
||||||
|
# These are host infrastructure. No module ever sees them, and no feature chooses its own sender:
|
||||||
|
# the From address below is the only one this platform can send as.
|
||||||
|
#
|
||||||
|
# EKKLESIA_SMTP_HOST is a bare hostname — never a URL, a port, or a connection string.
|
||||||
|
# EKKLESIA_SMTP_SECURITY is exactly one of:
|
||||||
|
# implicit-tls TLS from the first byte, conventionally port 465
|
||||||
|
# starttls plaintext connect then a *required* upgrade, conventionally 587 (the default)
|
||||||
|
# plaintext no TLS at all; development only, and production refuses it outright
|
||||||
|
# EKKLESIA_SMTP_PORT is optional and defaults to the conventional port for the mode.
|
||||||
|
#
|
||||||
|
# Credentials are optional **as a pair**, because a relay authorized by source IP has neither.
|
||||||
|
# Setting one without the other is refused rather than silently connecting anonymously. Never commit
|
||||||
|
# a real password: this file is in every clone.
|
||||||
|
#
|
||||||
|
# A Google Workspace SMTP Relay deployment typically looks like:
|
||||||
|
# EKKLESIA_SMTP_HOST=<the relay host from Google Workspace Admin>
|
||||||
|
# EKKLESIA_SMTP_PORT=587
|
||||||
|
# EKKLESIA_SMTP_SECURITY=starttls
|
||||||
|
# [email protected]
|
||||||
|
# Whether it also needs a username and password depends on the relay's authorization setting in
|
||||||
|
# Google Workspace Admin, which varies per deployment. See docs/development/email.md.
|
||||||
|
# EKKLESIA_SMTP_HOST=
|
||||||
|
# EKKLESIA_SMTP_PORT=587
|
||||||
|
# EKKLESIA_SMTP_SECURITY=starttls
|
||||||
|
# EKKLESIA_SMTP_USERNAME=
|
||||||
|
# EKKLESIA_SMTP_PASSWORD=
|
||||||
|
# [email protected]
|
||||||
|
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||||
|
#
|
||||||
|
# EKKLESIA_SMTP_HELO_NAME is the name Ekklesia announces itself as in the SMTP EHLO command.
|
||||||
|
#
|
||||||
|
# Leave it unset unless a relay refuses the connection. An SMTP client has to identify itself, and
|
||||||
|
# with nothing configured Nodemailer derives that from the machine hostname — substituting the
|
||||||
|
# address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal
|
||||||
|
# state of a container or an unnamed VM. Some relays close the connection on that greeting with a
|
||||||
|
# 421 before any message is offered, which looks exactly like the relay being unreachable.
|
||||||
|
#
|
||||||
|
# Set it to a fully qualified domain name your deployment owns — typically the sending domain.
|
||||||
|
# No scheme, no port, no brackets, and at least one dot; anything else is refused at startup.
|
||||||
|
# EKKLESIA_SMTP_HELO_NAME=example.org
|
||||||
|
|
||||||
|
# Where an emailed verification or password-reset link sends a browser (ADR 0038).
|
||||||
|
#
|
||||||
|
# Absent means the Dashboard is served from EKKLESIA_IDENTITY_BASE_URL, which is the ordinary
|
||||||
|
# same-origin production shape. Set it only when a deployment separates the two, for example an API
|
||||||
|
# at https://api.example.org and the Dashboard at https://dash.example.org. It must also appear in
|
||||||
|
# EKKLESIA_IDENTITY_TRUSTED_ORIGINS, or the identity system refuses the emailed link — Ekklesia
|
||||||
|
# checks that at startup rather than letting every recipient discover it.
|
||||||
|
#
|
||||||
|
# This is the authenticated Dashboard host, never the public church website.
|
||||||
|
# EKKLESIA_DASHBOARD_ORIGIN=https://dash.example.org
|
||||||
|
#
|
||||||
|
# Verification and password recovery need a working relay above. The initial platform-owner
|
||||||
|
# bootstrap (pnpm identity:create-initial-owner) deliberately does not: it creates that one account
|
||||||
|
# already verified, so a fresh server can be signed in to before any mail exists.
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
|
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
|
||||||
|
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
|
||||||
|
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
|
||||||
|
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
|
||||||
|
# correct on a supported release and on GitHub Actions, but on this server they are inert:
|
||||||
|
#
|
||||||
|
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
|
||||||
|
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
|
||||||
|
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
|
||||||
|
# is upgraded and configured, the token's authority is whatever the server grants — not what
|
||||||
|
# this file requests.
|
||||||
|
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
|
||||||
|
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
|
||||||
|
#
|
||||||
|
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
||||||
|
# itself. See docs/development/security.md.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
@@ -20,6 +37,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
@@ -44,6 +66,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Exercise PostgreSQL and S3 infrastructure
|
- name: Exercise PostgreSQL and S3 infrastructure
|
||||||
run: bash scripts/smoke-infrastructure.sh
|
run: bash scripts/smoke-infrastructure.sh
|
||||||
@@ -55,6 +82,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ monorepo:
|
|||||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
||||||
feature module, a surface, or an app.
|
feature module, a surface, or an app.
|
||||||
- **Modules** (`modules/*`, template at `templates/module`) are backend-only capability packages.
|
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||||
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 contain UI or import Core,
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||||
another module, or a surface directly.
|
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||||
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client
|
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||||
contracts only, never Core or module internals directly.
|
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||||
|
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||||
|
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||||
|
presentation concern a module does not. They consume public API/client contracts only, never Core
|
||||||
|
or module internals directly.
|
||||||
|
|
||||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||||
|
|||||||
+46
-5
@@ -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
|
||||||
@@ -33,10 +34,50 @@ pnpm infrastructure:config
|
|||||||
- Expose supported entry points through package `exports`; do not import another package's
|
- Expose supported entry points through package `exports`; do not import another package's
|
||||||
private source paths.
|
private source paths.
|
||||||
- Use `workspace:` ranges for internal package dependencies.
|
- Use `workspace:` ranges for internal package dependencies.
|
||||||
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other
|
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||||
UI source files.
|
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||||
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a
|
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||||
surface.
|
other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||||
|
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||||
|
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
|
||||||
|
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
|
||||||
|
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
|
||||||
|
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
|
||||||
|
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
|
||||||
|
presentation-mode flag, and the same versioned mount ABI.
|
||||||
|
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
|
||||||
|
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
|
||||||
|
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
|
||||||
|
stays rejected inside a module. Reference registered semantic tokens; never define an
|
||||||
|
`--ekklesia-*` custom property of your own.
|
||||||
|
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
||||||
|
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
||||||
|
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||||
|
private deployment detail and would break standalone hosting.
|
||||||
|
- The containing application shell, navigation, global theme, and platform policy belong to Core and
|
||||||
|
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
|
||||||
|
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||||
|
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||||
|
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
||||||
|
- A module may contribute a browser renderer to a capability another module provides
|
||||||
|
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
|
||||||
|
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
|
||||||
|
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
|
||||||
|
ordering. A contributed renderer gets its own id and that input, and nothing else.
|
||||||
|
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
||||||
|
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
||||||
|
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
||||||
|
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
|
||||||
|
display-scoped service worker caches the shell and declared artifacts only, network-first, and
|
||||||
|
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
|
||||||
|
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
||||||
|
module, child module, owning module, or group-derived dependency: real module relationships are
|
||||||
|
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
||||||
|
ordering, startup, or authorization (ADR 0027). A module declares its display name
|
||||||
|
(`manifest.name`); the Dashboard owns where it appears.
|
||||||
|
- A surface is browser code. It may depend on browser-safe shared roles only — never
|
||||||
|
`@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the
|
||||||
|
browser — and it reaches the server over HTTP.
|
||||||
- Add or update tests when changing a contract or architectural rule.
|
- Add or update tests when changing a contract or architectural rule.
|
||||||
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
# Ekklesia
|
# Ekklesia
|
||||||
|
|
||||||
Ekklesia is an open, modular church technology platform for composing backend capabilities
|
Ekklesia is an open, modular church technology platform. Each module is an independently versioned
|
||||||
into independently developed dashboards, websites, mobile apps, kiosks, displays, and other
|
product that owns its domain behavior, data, and its own UI, composed into dashboards, websites,
|
||||||
surfaces.
|
mobile apps, kiosks, displays, and other surfaces.
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
||||||
Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The
|
Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP
|
||||||
repository includes
|
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
|
||||||
|
against an independent security audit) is complete: all five passes have landed and the final
|
||||||
|
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
||||||
|
not-yet-deployed threat model.** Phase 6 completed through 6D, and **Phase 7A passes 1 (the minimal
|
||||||
|
operational module lifecycle), 2 (the
|
||||||
|
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
|
||||||
|
browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
|
||||||
|
kiosk host) have landed. Phase 7B pass 1 adds the Dashboard's host-owned browser sign-in, sign-out,
|
||||||
|
session-expiry handling, and authenticated-shell transition.** The operational
|
||||||
|
requirements that remain are prerequisites for two specific *future* activities — executing
|
||||||
|
untrusted CI contributions, and a public production launch — and neither gates ongoing product
|
||||||
|
development; see the
|
||||||
|
pass 5 closure below and
|
||||||
|
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes
|
||||||
public module contracts, independently versioned capabilities, static module manifests,
|
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
|
||||||
@@ -32,14 +46,19 @@ in `@ekklesia/contracts`, and an explicit Better Auth construction boundary in C
|
|||||||
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root
|
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root
|
||||||
(`pnpm api:start`) mounts Better Auth at `/api/auth/*` and exposes `GET /api/v1/principal` behind
|
(`pnpm api:start`) mounts Better Auth at `/api/auth/*` and exposes `GET /api/v1/principal` behind
|
||||||
Core's own Fetch-compatible identity runtime — Core still does not depend on Hono, and modules
|
Core's own Fetch-compatible identity runtime — Core still does not depend on Hono, and modules
|
||||||
still cannot reach any of this. Public email/password registration is intentional; every account
|
still cannot reach any of this. Public email/password registration is the intended eventual
|
||||||
receives identical lowest-privilege baseline status and signup order never grants elevation. See
|
product policy — every account receives identical lowest-privilege baseline status and signup
|
||||||
|
order never grants elevation — but is now **disabled by default, and cannot be enabled in
|
||||||
|
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
|
||||||
@@ -51,9 +70,9 @@ on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint
|
|||||||
accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
|
accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
|
||||||
[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: a version-3 module
|
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
|
||||||
manifest declares permissions as complete `{id, baseline, description}` declarations (never a bare
|
(version 3 at the time, version 6 today) declares permissions as complete
|
||||||
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
|
||||||
enforceable and grantable through the exact same `requirePermission` middleware and
|
enforceable and grantable through the exact same `requirePermission` middleware and
|
||||||
@@ -80,13 +99,776 @@ See [API](docs/development/api.md#module-http-routes),
|
|||||||
[Module development](docs/module-development/README.md#http-routes), and
|
[Module development](docs/module-development/README.md#http-routes), and
|
||||||
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
|
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
|
||||||
|
|
||||||
|
Phase 5C gives a module's own lifecycle hooks and HTTP handlers typed access to their own
|
||||||
|
PostgreSQL tables. `context.services.database` — a restricted, typed `ModuleDatabase` view over
|
||||||
|
Drizzle's `select`/`selectDistinct`/`selectDistinctOn`/`insert`/`update`/`delete`/`execute`/
|
||||||
|
`transaction`, never a raw `pg` client, pool, or connection string — is available to `start`,
|
||||||
|
`stop`, and every authorized route handler, and reuses the one PostgreSQL pool `apps/api` already
|
||||||
|
owns; registration remains declarative and database-free. Modules continue to own and import their
|
||||||
|
own Drizzle schemas and generated migrations, applied explicitly and never automatically at
|
||||||
|
startup. See [API](docs/development/api.md#module-database-access),
|
||||||
|
[Module development](docs/module-development/README.md#database-access), and
|
||||||
|
[ADR 0016](docs/architecture/decisions/0016-module-database-access.md).
|
||||||
|
|
||||||
|
Phase 5D pass 1 of 5 hardens the public request perimeter against an independent security audit
|
||||||
|
of the Phase 5C checkpoint, without adding a Surface, a product module, or any new capability.
|
||||||
|
Better Auth's authentication rate limiter now resolves client identity from a server-owned
|
||||||
|
client-address boundary (`core/src/identity/client-address.ts`, wired in at the Node/API transport
|
||||||
|
boundary) — direct mode trusts only the real socket peer by default, and an explicit trusted-proxy
|
||||||
|
mode is required before any forwarded header is ever consulted, closing a rotating-header
|
||||||
|
rate-limit bypass. Public sign-up is now **disabled by default, and a production configuration that
|
||||||
|
tries to enable it fails closed** (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, reported by `pnpm
|
||||||
|
doctor` as a blocking failure) — the durable abuse-control prerequisites do not exist yet, so only
|
||||||
|
development/test composition may enable it. Every public body-consuming route — Better Auth,
|
||||||
|
Core's grant-mutation endpoint, and every module route (via a new, required `maxBodyBytes` field on
|
||||||
|
the executable route contract) — now has an explicit, platform-enforced byte ceiling with one
|
||||||
|
sanitized `413` contract. The Node HTTP server now has finite header/request timeouts, whose real
|
||||||
|
transport-level failures are observed and logged in sanitized form. See
|
||||||
|
[API](docs/development/api.md), [Module development](docs/module-development/README.md#http-routes),
|
||||||
|
and [ADR 0017](docs/architecture/decisions/0017-public-request-perimeter-hardening.md). **This pass
|
||||||
|
does not add distributed rate limiting, durable public-signup abuse control, or any deployment/TLS
|
||||||
|
guarantee, and it did not by itself complete Phase 5D** — at the close of this pass, passes 2–5
|
||||||
|
(identity/database transport hardening, authorization integrity, module/runtime/migration
|
||||||
|
correctness, and deployment/supply-chain closure plus a final independent re-audit) were still
|
||||||
|
outstanding, and Phase 6 (Surface work) was blocked until they landed.
|
||||||
|
|
||||||
|
Phase 5D pass 2 of 5 corrects identity session resolution and enforces a database transport policy.
|
||||||
|
A request carrying no cookies, or only unrelated cookies, now performs **no** identity database
|
||||||
|
work at all; only a request presenting the configured session cookie that Better Auth resolved to
|
||||||
|
`null` is disambiguated, by a real read of the session relation and the exact columns the adapter
|
||||||
|
depends on (under `where false`, so no row is ever read) rather than the previous `select 1`. A
|
||||||
|
missing table, dropped column, revoked privilege, unreachable database, or query timeout therefore
|
||||||
|
produces a sanitized `503`, never a silent anonymous downgrade. The identity base URL and every
|
||||||
|
trusted origin must now be exactly one canonical origin, and production rejects the full
|
||||||
|
loopback/unspecified address space rather than three literal spellings. **Production PostgreSQL
|
||||||
|
connections now require TLS with certificate *and* hostname verification** — from the API, both
|
||||||
|
migration commands, the bootstrap CLI, and `pnpm doctor` alike — with no encryption-only mode, no
|
||||||
|
`rejectUnauthorized: false` escape hatch, no plaintext exception (not even for a loopback
|
||||||
|
database), and an explicit refusal of a `DATABASE_URL` that tries to carry its own TLS parameters,
|
||||||
|
which the pinned driver would otherwise let silently override the policy. See
|
||||||
|
[Identity](docs/development/identity.md), [API](docs/development/api.md#database-transport-tls), and
|
||||||
|
[ADR 0018](docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||||||
|
**This pass adds no secret store, no certificate rotation, no client-certificate authentication, and
|
||||||
|
no distributed rate limiting; TLS protects the connection, not the exposure, so the network boundary
|
||||||
|
remains the deployment's own responsibility. This pass did not by itself complete Phase 5D** — at
|
||||||
|
its close, passes 3–5 (authorization integrity, module/runtime/migration correctness, and
|
||||||
|
deployment/supply-chain closure plus a final independent re-audit) were still outstanding.
|
||||||
|
|
||||||
|
Phase 5D pass 3 of 5 preserves authorization security history and makes explicit-grant management
|
||||||
|
unambiguous and bounded. Authorization **audit history now survives user deletion**: the audit
|
||||||
|
subject is a retained identifier snapshot with no cascading foreign key, so deleting a user still
|
||||||
|
removes their live grants and platform-owner row but no longer destroys the record of what they had
|
||||||
|
been granted and by whom. Platform-owner bootstrap's single use moved to a dedicated singleton with
|
||||||
|
no user foreign key — previously deleting the bootstrapped owner's user row silently reopened the
|
||||||
|
one operation that establishes a platform's first administrator; the operational consequence is that
|
||||||
|
**deleting the last owner does not reactivate bootstrap**, and existing installations are backfilled
|
||||||
|
as consumed by the new migration. Two equivalent grants can no longer both be effective: creation
|
||||||
|
conflicts with a sanitized `409`, serialized under real PostgreSQL concurrency, and one revocation
|
||||||
|
now clears every unrevoked equivalent with a per-row audit entry, so revoking a grant can no longer
|
||||||
|
report success while a duplicate keeps authorizing the user. Grant ids must be canonical UUIDs and
|
||||||
|
expirations one exact future-only format, both decided before any database call; the generic HTTP
|
||||||
|
surface creates unscoped grants only; and listing is bounded keyset pagination with an opaque,
|
||||||
|
unsigned position cursor. One forward Core migration (`0002_authorization_integrity`,
|
||||||
|
`{"mode":"none"}`) must be applied before the new code starts. See
|
||||||
|
[Authorization](docs/development/authorization.md) and
|
||||||
|
[ADR 0019](docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
|
||||||
|
**This pass adds no roles, teams, tenants, service accounts, scope resolver, user-deletion endpoint,
|
||||||
|
or owner-recovery workflow; audit append-only behavior remains an application convention, not a
|
||||||
|
database or cryptographic guarantee. This pass did not by itself complete Phase 5D** — at its close,
|
||||||
|
passes 4 (module/runtime/migration correctness) and 5 (deployment/supply chain plus a final
|
||||||
|
independent re-audit) were still outstanding, and Phase 6 was blocked until they landed.
|
||||||
|
|
||||||
|
Phase 5D pass 4 of 5 hardens the module runtime and the migration commands. **Two routes of one
|
||||||
|
module that could both match the same request path are now rejected at registration**
|
||||||
|
(`MODULE_ROUTE_OVERLAP`) instead of being resolved by declaration order — with different permissions
|
||||||
|
on `/events/:id` and `/events/new`, registration order alone previously decided which permission
|
||||||
|
guarded `/events/new`. Decoded route parameters are documented as untrusted, module-owned input that
|
||||||
|
handlers must validate against their own domain grammar; no generic validator or route field was
|
||||||
|
added. A module whose `start` hook fails is now rolled back through **its own** `stop` hook first,
|
||||||
|
so partially acquired resources are released, and every stop/rollback hook runs under a fixed
|
||||||
|
10-second deadline while HTTP shutdown drains for at most 30 seconds before force-closing
|
||||||
|
connections through Node's supported public APIs — shutdown is finite at every stage, reports a
|
||||||
|
forced drain or a hook failure as an unsuccessful shutdown, and always still reaches pool closure.
|
||||||
|
`migrations:plan`/`migrations:apply` now connect with fixed 10-second connection, query, and
|
||||||
|
`statement_timeout` defaults that a `DATABASE_URL` cannot weaken (the pinned driver lets a
|
||||||
|
connection string override explicit options — verified), and each migration's SQL is sent to
|
||||||
|
PostgreSQL **intact, as one parsed batch** inside the existing transaction rather than split on the
|
||||||
|
literal `--> statement-breakpoint` bytes, which corrupted any migration containing those bytes in a
|
||||||
|
string literal, dollar-quoted body, quoted identifier, or comment. See
|
||||||
|
[Module development](docs/module-development/README.md#http-routes),
|
||||||
|
[API](docs/development/api.md#graceful-shutdown), and
|
||||||
|
[ADR 0020](docs/architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md).
|
||||||
|
**Deadlines bound how long the platform waits; they do not cancel or sandbox trusted in-process
|
||||||
|
module code, and closing a socket does not prove a handler promise ended. No custom SQL parser
|
||||||
|
exists. Modules remain trusted compiled backend code with no process, role, schema, or tenant
|
||||||
|
isolation. This pass did not by itself complete Phase 5D** — at its close, pass 5
|
||||||
|
(deployment/supply chain plus an independent whole-repository re-audit) was still outstanding, and
|
||||||
|
Phase 6 was blocked until it landed.
|
||||||
|
|
||||||
|
Phase 5D pass 5 of 5 closes the phase with an independent whole-repository re-audit and the
|
||||||
|
repository-local half of deployment and supply-chain hardening. The re-audit swept every layer —
|
||||||
|
Core, contracts, `apps/api`, scripts, the CI workflow, and documentation — and its repository-local
|
||||||
|
findings are resolved here: every dependency advisory is remediated (both `pnpm audit` and
|
||||||
|
`pnpm audit --prod` report no known vulnerabilities), every CI action reference is pinned to a full
|
||||||
|
immutable commit SHA, installs run `--frozen-lockfile`, no workflow references a repository or
|
||||||
|
instance secret, every checkout step sets `persist-credentials: false`, a checksum-verified secret
|
||||||
|
scan found no committed credential in the history, and one canonical
|
||||||
|
[security document](docs/development/security.md) now states the trusted-module model, the
|
||||||
|
undeployed state, and the launch checklist in a single place. **CI is accepted as a
|
||||||
|
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
||||||
|
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
||||||
|
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
||||||
|
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
|
||||||
|
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
|
||||||
|
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
|
||||||
|
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||||||
|
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||||||
|
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||||||
|
development proceeds under the current trusted-contributor, not-yet-deployed model. Runner topology
|
||||||
|
and job-token authority remain unverified and are not described as isolated. See
|
||||||
|
[Security model and deployment trust boundaries](docs/development/security.md).
|
||||||
|
|
||||||
|
Phase 6A established the **module product contract**. Ekklesia's modules are no
|
||||||
|
longer backend-only capability packages: a module is an independently versioned *product* that owns
|
||||||
|
its domain behavior, data, permissions, routes, migrations, and — new in this pass — **its own UI**.
|
||||||
|
Core keeps the containing application shell and every platform policy (authentication, authorization
|
||||||
|
enforcement, platform context, top-level routing, navigation, global theme, error boundaries,
|
||||||
|
trusted-origin/CORS). Modules stay **flat peers**: `video`, `livestream`, and `announcements` are
|
||||||
|
independent first-class modules with no parent/child relationship, and a Dashboard folder such as
|
||||||
|
`Media` is presentation configuration an administrator can reorganize, never module architecture and
|
||||||
|
never a dependency. The long-term goal is one module implementation hosted by Ekklesia *and*, later,
|
||||||
|
by a standalone application — never two codebases for one domain. See
|
||||||
|
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
|
||||||
|
|
||||||
|
**What that pass shipped at its checkpoint was the static contract only.** `manifestVersion: 4`
|
||||||
|
added one optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
|
||||||
|
during discovery exactly like `server` and `schema` — inside the package, real file, no source
|
||||||
|
TypeScript. Discovery stayed inert: **nothing imported, mounted, or rendered that entrypoint**,
|
||||||
|
exactly like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest was a
|
||||||
|
one-character edit; version 3 was rejected outright rather than reinterpreted, as version 2 was
|
||||||
|
before it. The Dashboard shell, runtime mounting, and navigation named as absent at that checkpoint
|
||||||
|
landed in subsequent Phase 6 passes. A standalone host, module installer or marketplace, hot-loading,
|
||||||
|
and every real feature module still do not exist. Restart-based discovery, explicit operator-invoked
|
||||||
|
migrations, and the trusted compiled-module model are unchanged.
|
||||||
|
|
||||||
|
Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
|
||||||
|
and what a host gives it.** Module UI is authored in **React and TypeScript**, and a module package
|
||||||
|
may now contain `.tsx` and depend on `react`/`react-dom` — every other UI framework stays rejected by
|
||||||
|
`pnpm boundaries`, and the layer rules are untouched, so module UI still cannot import Core, an app,
|
||||||
|
a surface, or another module. React is the supported *authoring* model but is deliberately **not** in
|
||||||
|
the contract: a module's compiled `entrypoints.ui` artifact default-exports
|
||||||
|
`defineModuleUi({ id, version, mount })`, and `mount(container, context)` receives a DOM element and
|
||||||
|
returns an unmount function. A host needs an element and a context, never a matching framework.
|
||||||
|
|
||||||
|
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
|
||||||
|
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
|
||||||
|
host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and
|
||||||
|
`realtime` — and nothing else. **A module UI asks its host for its own API by relative path**
|
||||||
|
(`api.request("posts")`)
|
||||||
|
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
|
||||||
|
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
|
||||||
|
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
|
||||||
|
hints are presentation only** — server-side `requirePermission` remains the sole authority — and
|
||||||
|
**module UI is trusted code in the host's own browser realm**: no iframe, no sandbox, matching the
|
||||||
|
backend trust model rather than pretending to improve on it. See
|
||||||
|
[ADR 0022](docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md).
|
||||||
|
|
||||||
|
Core still never loads browser code: discovery validates the `entrypoints.ui` path and never
|
||||||
|
evaluates it, and hosts do their own dynamic import.
|
||||||
|
|
||||||
|
Phase 6B pass 2 makes that contract **buildable end to end**. A module author writes React and
|
||||||
|
TypeScript at `src/ui/index.tsx` and runs its normal `pnpm build`; **esbuild** compiles it to the
|
||||||
|
browser ESM artifact the manifest declares. Authors write no bundler configuration — the new
|
||||||
|
`@ekklesia/module-ui-build` package owns all of it, and because it uses the existing `tooling` role
|
||||||
|
that modules could already depend on, **this pass required no boundary relaxation at all**. The
|
||||||
|
output path is read from the manifest rather than a build convention, so the declared and produced
|
||||||
|
artifacts cannot drift.
|
||||||
|
|
||||||
|
**React is bundled into each artifact rather than shared from the host.** That is the decision ADR
|
||||||
|
0022 implies: `mount()` is the ABI precisely so React is *not* part of the host contract, and
|
||||||
|
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
||||||
|
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
||||||
|
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
||||||
|
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
|
||||||
|
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
|
||||||
|
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||||||
|
and installing a module or starting the API never triggers one.
|
||||||
|
|
||||||
|
`templates/module` is the canonical example and now ships optional UI. Its tests build the artifact
|
||||||
|
from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered
|
||||||
|
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).
|
||||||
|
|
||||||
|
Phase 6B pass 3 closes the last two foundational gaps. **The browser boundary now actually holds**:
|
||||||
|
`@ekklesia/module-ui-sdk` had one type-only import of `@ekklesia/contracts`, whose barrel pulled
|
||||||
|
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
|
||||||
|
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||||||
|
declarations, typed facades, and frozen contexts are correctness boundaries, not a sandbox. Ekklesia
|
||||||
|
is also **not currently deployed**: there is no production instance, reverse proxy, public listener,
|
||||||
|
or deployment automation, so the origin/proxy/throttling properties a public launch requires are
|
||||||
|
unproven launch-blockers rather than existing guarantees. Read
|
||||||
|
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
||||||
|
module or exposing the API.
|
||||||
|
|
||||||
|
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
|
||||||
|
None of them is a feature module, and none of them names one.
|
||||||
|
|
||||||
|
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
|
||||||
|
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
|
||||||
|
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
|
||||||
|
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
|
||||||
|
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
|
||||||
|
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
|
||||||
|
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
|
||||||
|
badge, toast, and empty/error/loading states), every value a token reference, with real
|
||||||
|
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
|
||||||
|
exactly two shared roles to modules by name and pins both design packages beneath every other role.
|
||||||
|
The Dashboard and display install the defaults at their document root; custom properties inherit into
|
||||||
|
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
|
||||||
|
theme editor, brand builder, second theme, or component catalogue exists.**
|
||||||
|
|
||||||
|
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
|
||||||
|
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
|
||||||
|
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
|
||||||
|
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
|
||||||
|
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
|
||||||
|
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
|
||||||
|
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
|
||||||
|
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
|
||||||
|
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
|
||||||
|
is versioned with no dual acceptance; migrating is one `pnpm build`.
|
||||||
|
|
||||||
|
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||||||
|
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||||
|
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
|
||||||
|
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||||
|
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||||||
|
means. Publication requires that another eligible module provides the capability inside the declared
|
||||||
|
range; a contribution that fails is absent rather than broken. A consumer discovers through
|
||||||
|
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
|
||||||
|
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
|
||||||
|
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
|
||||||
|
database, storage, or credential — and loading one grants its module nothing.
|
||||||
|
|
||||||
|
Phase 8 pass 4 closes the object-storage write path
|
||||||
|
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
|
||||||
|
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
|
||||||
|
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
|
||||||
|
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
|
||||||
|
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
|
||||||
|
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
|
||||||
|
oversize is refused before authorization, the buffered path now counts what arrived rather than
|
||||||
|
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
|
||||||
|
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
|
||||||
|
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
|
||||||
|
|
||||||
|
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
|
||||||
|
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
|
||||||
|
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
|
||||||
|
every contributed renderer that display mounted, network-first so a screen can never be trapped on
|
||||||
|
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
|
||||||
|
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
|
||||||
|
stated rather than papered over — a display cannot render module content while offline, because
|
||||||
|
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
|
||||||
|
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
|
||||||
|
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
|
||||||
|
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
|
||||||
|
the module's responsibility and do not exist yet.**
|
||||||
|
|
||||||
|
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||||
|
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||||
|
|
||||||
## Quick start
|
## 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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
cp .env.example .env
|
cp .env.example .env # keep its NODE_ENV=development: see below
|
||||||
pnpm infrastructure:up
|
pnpm infrastructure:up
|
||||||
pnpm modules:validate
|
pnpm modules:validate
|
||||||
pnpm migrations:validate
|
pnpm migrations:validate
|
||||||
@@ -98,9 +880,25 @@ 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, optional read-only PostgreSQL
|
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
|
||||||
planning, and the current object-storage integration state. It never applies migrations, creates a
|
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage and
|
||||||
history table, starts module servers, or performs storage operations. Schema discovery does import
|
outbound-email integration state.
|
||||||
|
|
||||||
|
Doctor reads its host infrastructure configuration through the same
|
||||||
|
`loadHostConfiguration(process.env)` the API executable uses, so both derive identical settings and
|
||||||
|
identical defaults from one deployment environment: configured object storage is reported as
|
||||||
|
configured, a module secret the deployment has set is reported as satisfied, and a value neither
|
||||||
|
process could accept is reported as a configuration failure rather than silently interpreted one way
|
||||||
|
by the diagnostic and another way at startup. An optional service that is genuinely absent — no
|
||||||
|
`S3_ENDPOINT`, no `EKKLESIA_SMTP_HOST` — is still a complete deployment and reported as skipped.
|
||||||
|
|
||||||
|
`.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
||||||
|
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
||||||
|
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
|
||||||
|
development infrastructure provides is only usable with it set.
|
||||||
|
|
||||||
|
Doctor never applies migrations, creates a history table, starts module servers, or performs
|
||||||
|
storage operations. Schema discovery does import
|
||||||
compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free.
|
compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free.
|
||||||
|
|
||||||
With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped
|
With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped
|
||||||
@@ -119,17 +917,28 @@ 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
|
||||||
2 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: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
|
||||||
|
TLS required with certificate and hostname verification.
|
||||||
○ Connection check skipped
|
○ Connection check skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
○ Migration plan skipped
|
○ Migration plan skipped
|
||||||
@@ -139,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
|
||||||
```
|
```
|
||||||
@@ -166,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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
+368
-8
@@ -1,15 +1,43 @@
|
|||||||
import { isAuthenticatedPrincipal, type RegisteredModuleHttpRoute } from "@ekklesia/contracts";
|
import {
|
||||||
|
isAuthenticatedPrincipal,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
type ModuleSecretSource,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
AuthorizationEvaluator,
|
AuthorizationEvaluator,
|
||||||
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 type {
|
||||||
|
DiscoveredModule,
|
||||||
|
ModuleConfigurationLookup,
|
||||||
|
ModuleConfigurationSettings,
|
||||||
|
ModuleConfigurationStore,
|
||||||
|
ModuleLifecycleSnapshot,
|
||||||
|
ModuleLifecycleStore,
|
||||||
|
ModuleRealtimeRegistry,
|
||||||
|
ModuleStorageLookup,
|
||||||
|
ModuleUiPublicationRegistry,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context } from "hono";
|
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
|
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.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 { mountModuleHttpRoutes } from "./module-http-routes.js";
|
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||||
|
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||||
|
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||||
|
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||||
|
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||||
|
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||||
|
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||||
|
|
||||||
export interface CreateApiAppAuthorizationOptions {
|
export interface CreateApiAppAuthorizationOptions {
|
||||||
readonly evaluator: AuthorizationEvaluator;
|
readonly evaluator: AuthorizationEvaluator;
|
||||||
@@ -28,6 +56,87 @@ 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
|
||||||
|
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
||||||
|
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
||||||
|
* `ModuleDatabase` for the lifetime of this API runtime. */
|
||||||
|
readonly moduleServices: ModuleRuntimeServices;
|
||||||
|
/** The 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;
|
||||||
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
|
||||||
@@ -42,6 +151,125 @@ 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
|
||||||
|
* dynamic-function `origin` — including one that already has its own `Vary` (module responses
|
||||||
|
* force `Origin`/`Cookie`; `/api/v1/principal` sets `Origin, Cookie` directly; an `OPTIONS`
|
||||||
|
* preflight sets its own) — so the header `cors()` itself leaves behind can still be an invalid
|
||||||
|
* combined "*, Origin" or contain a case-different duplicate. This wraps `middleware` and
|
||||||
|
* canonicalizes whatever `Vary` remains only *after* it has fully finished, using
|
||||||
|
* `canonicalizeVaryHeader` — the same shared parsing/dedup primitive `mergedVaryHeader` (module
|
||||||
|
* responses) builds on, so there is one implementation to keep in sync, not two that could diverge
|
||||||
|
* — but never `mergedVaryHeader` itself: this boundary wraps *every* response, `OPTIONS` preflights
|
||||||
|
* included, and must never inject `Origin`/`Cookie` onto a response that never had them, only
|
||||||
|
* de-duplicate what is already there. Two return shapes have to be handled: a normal request lets
|
||||||
|
* `middleware` call `next()` and mutate `c.res` in place, so the fix-up goes through `c.header()`
|
||||||
|
* (reassigning `c.res` directly triggers Hono's own header-preserving merge and would silently
|
||||||
|
* restore the uncanonicalized value); an `OPTIONS` preflight makes `cors()` return a new `Response`
|
||||||
|
* directly without ever touching `c.res`, so the fix-up mutates that returned response's headers
|
||||||
|
* instead. Neither path reads or clones the response body, so a streaming body is untouched either
|
||||||
|
* way. Status, body, content type, ordinary headers, authorization, trusted-origin behavior, and
|
||||||
|
* route ordering are all unaffected — only the final `Vary` value changes.
|
||||||
|
*/
|
||||||
|
function withCanonicalVary(middleware: MiddlewareHandler): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
const result = await middleware(c, next);
|
||||||
|
if (result instanceof Response) {
|
||||||
|
const existingVary = result.headers.get("Vary");
|
||||||
|
if (existingVary !== null) {
|
||||||
|
result.headers.set("Vary", canonicalizeVaryHeader(existingVary));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const existingVary = c.res.headers.get("Vary");
|
||||||
|
if (existingVary !== null) {
|
||||||
|
c.header("Vary", canonicalizeVaryHeader(existingVary));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure Hono application factory: no I/O, no environment reads, no process side effects, and it
|
* Pure Hono application factory: no I/O, no environment reads, no process side effects, and it
|
||||||
* never invokes a module route handler while composing routes. Suitable for unit and integration
|
* never invokes a module route handler while composing routes. Suitable for unit and integration
|
||||||
@@ -58,19 +286,40 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
const corsMiddleware = cors({
|
const corsMiddleware = withCanonicalVary(
|
||||||
|
cors({
|
||||||
allowHeaders: ["Content-Type"],
|
allowHeaders: ["Content-Type"],
|
||||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
maxAge: 600,
|
maxAge: 600,
|
||||||
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
|
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
app.use("/api/auth/*", corsMiddleware);
|
app.use("/api/auth/*", corsMiddleware);
|
||||||
app.use("/api/v1/*", corsMiddleware);
|
app.use("/api/v1/*", corsMiddleware);
|
||||||
|
|
||||||
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
|
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
|
||||||
|
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
|
||||||
|
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
|
||||||
|
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
|
||||||
|
// policy to the response.
|
||||||
|
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
|
||||||
|
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "NOT_FOUND" }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
|
||||||
|
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
|
||||||
try {
|
try {
|
||||||
return await runtime.handleAuthRequest(c.req.raw);
|
const response = await runtime.handleAuthRequest(c.req.raw);
|
||||||
|
// Every Better Auth response — including a session cookie — must never be cached by an
|
||||||
|
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
|
||||||
|
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
|
||||||
|
// and never reads or buffers the body.
|
||||||
|
return browserSafeAuthResponse(c.req.raw, response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sanitizedErrorResponse(c, error, 500);
|
return sanitizedErrorResponse(c, error, 500);
|
||||||
}
|
}
|
||||||
@@ -103,10 +352,121 @@ 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,
|
||||||
trustedOrigins: allowedOrigins,
|
trustedOrigins: allowedOrigins,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
type ModuleRouteBodyMode,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
||||||
|
* across every route file. Every limit below is deliberately small relative to the actual payload
|
||||||
|
* shapes it guards:
|
||||||
|
*
|
||||||
|
* - `AUTH_REQUEST_MAX_BODY_BYTES` (8 KiB): every enabled Better Auth route
|
||||||
|
* (`/api/auth/sign-up/email`, `/sign-in/email`, `/sign-out`, ...) accepts only an email, a
|
||||||
|
* password (bounded to 128 characters — `core/src/identity/runtime.ts`), and an optional short
|
||||||
|
* display name; the pinned release adds no other enabled provider or field (ADR 0011). A real
|
||||||
|
* payload is well under 200 bytes; 8 KiB leaves generous headroom for a verbose email address or
|
||||||
|
* name while remaining two orders of magnitude below the module ceiling.
|
||||||
|
* - `CORE_MUTATION_MAX_BODY_BYTES` (16 KiB): the one Core JSON mutation body, explicit-grant
|
||||||
|
* creation (`POST /api/v1/grants`), accepts `subjectUserId` (<=128 chars), `permission` (a bounded
|
||||||
|
* identifier), an optional `scope` (<=256 chars), and an optional ISO `expiresAt` string — every
|
||||||
|
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
||||||
|
* abuse.
|
||||||
|
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
||||||
|
* constant a module's own **buffered** route declaration is validated against at registration) is
|
||||||
|
* the ceiling for every body the platform materializes before a handler runs. It has not moved.
|
||||||
|
* - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
|
||||||
|
* explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
|
||||||
|
* `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
|
||||||
|
* single request body, and no unbounded option exists anywhere in the contract.
|
||||||
|
*/
|
||||||
|
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
||||||
|
export {
|
||||||
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
||||||
|
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
||||||
|
* size, a declared Content-Length, or any body content. */
|
||||||
|
export function requestBodyTooLargeResponse(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
|
||||||
|
*
|
||||||
|
* A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
|
||||||
|
* the byte counting below bounds the request either way, so refusing here could only ever turn a
|
||||||
|
* legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
|
||||||
|
* parser rejects genuinely malformed framing before this code sees the request at all.
|
||||||
|
*/
|
||||||
|
function declaredContentLength(request: Request): number | undefined {
|
||||||
|
const header = request.headers.get("content-length");
|
||||||
|
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Number.parseInt(header.trim(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
|
||||||
|
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
|
||||||
|
function releaseUnreadBody(request: Request): void {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body || body.locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void body.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
|
||||||
|
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
|
||||||
|
* chunk past the limit rather than the whole upload.
|
||||||
|
*/
|
||||||
|
async function readBounded(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
maxSize: number,
|
||||||
|
): Promise<readonly Uint8Array[] | "over-limit"> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
size += value.byteLength;
|
||||||
|
if (size > maxSize) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
return "over-limit";
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's one request-body limiter, for every route in the application.
|
||||||
|
*
|
||||||
|
* ## Both modes reject a declared oversize before anything else happens
|
||||||
|
*
|
||||||
|
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
|
||||||
|
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
|
||||||
|
* too-large upload takes.
|
||||||
|
*
|
||||||
|
* ## `buffered` — the body is materialized, bounded, before the handler runs
|
||||||
|
*
|
||||||
|
* The exact behaviour every route had before streaming existed, now with the counting done here
|
||||||
|
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
|
||||||
|
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
|
||||||
|
* what actually arrived rather than by what was claimed. The handler then receives a request whose
|
||||||
|
* body is fully available and provably within its declared allowance — which is what makes
|
||||||
|
* `await request.json()` safe in a module handler.
|
||||||
|
*
|
||||||
|
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
|
||||||
|
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
|
||||||
|
*
|
||||||
|
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
|
||||||
|
*
|
||||||
|
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
|
||||||
|
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
|
||||||
|
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
|
||||||
|
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
|
||||||
|
* is not a limit.
|
||||||
|
*
|
||||||
|
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
|
||||||
|
* handler has started, so a handler that persists as it reads has to be prepared for a partially
|
||||||
|
* consumed upload. Writing to a temporary key and committing only after the read completes is the
|
||||||
|
* pattern; the platform cannot do it for you, because only the module knows what a partial write
|
||||||
|
* means to its own data.
|
||||||
|
*
|
||||||
|
* ## Ordering
|
||||||
|
*
|
||||||
|
* This runs before permission evaluation for both modes, so a request that is *declared* too large
|
||||||
|
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
|
||||||
|
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
|
||||||
|
* push a large upload through the platform at all.
|
||||||
|
*
|
||||||
|
* ## Timeouts
|
||||||
|
*
|
||||||
|
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
|
||||||
|
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
|
||||||
|
* platform already has one answer for that rather than a second per-route one.
|
||||||
|
*/
|
||||||
|
export function platformBodyLimit(
|
||||||
|
maxSize: number,
|
||||||
|
mode: ModuleRouteBodyMode = "buffered",
|
||||||
|
): MiddlewareHandler {
|
||||||
|
return async function enforceBodyLimit(c, next) {
|
||||||
|
const request = c.req.raw;
|
||||||
|
const declared = declaredContentLength(request);
|
||||||
|
if (declared !== undefined && declared > maxSize) {
|
||||||
|
return requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
if (!request.body) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "buffered") {
|
||||||
|
const chunks = await readBounded(request.body, maxSize);
|
||||||
|
if (chunks === "over-limit") {
|
||||||
|
return requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
c.req.raw = new Request(request, {
|
||||||
|
body: streamOf(chunks),
|
||||||
|
// Required by undici whenever a `Request` is constructed from a stream.
|
||||||
|
duplex: "half",
|
||||||
|
} as RequestInit);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let overLimit = false;
|
||||||
|
let total = 0;
|
||||||
|
const counted = request.body.pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
total += chunk.byteLength;
|
||||||
|
if (total > maxSize) {
|
||||||
|
overLimit = true;
|
||||||
|
// Erroring the transform propagates back to the source and cancels it, so the socket is
|
||||||
|
// released rather than left draining an upload nobody will read.
|
||||||
|
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
if (overLimit) {
|
||||||
|
c.res = requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
releaseUnreadBody(c.req.raw);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { type ClientAddressResolver, TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
|
||||||
|
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||||
|
|
||||||
|
type NodeServerBindings = HttpBindings | Http2Bindings;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ClientAddressConfig,
|
||||||
|
ClientAddressConfigError,
|
||||||
|
type ClientAddressConfigErrorCode,
|
||||||
|
type ClientAddressConfigInput,
|
||||||
|
type ClientAddressFailureReason,
|
||||||
|
type ClientAddressMode,
|
||||||
|
type ClientAddressResolution,
|
||||||
|
type ClientAddressResolver,
|
||||||
|
createClientAddressConfig,
|
||||||
|
createClientAddressResolver,
|
||||||
|
type DirectClientAddressConfig,
|
||||||
|
findInvalidTrustedProxies,
|
||||||
|
normalizeClientAddress,
|
||||||
|
type TrustedProxyClientAddressConfig,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
|
||||||
|
/** A fixed, generic diagnostic — never the peer address, forwarded header content, request path, or
|
||||||
|
* any other request detail — logged only when client-address resolution fails closed. */
|
||||||
|
export const CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE =
|
||||||
|
"[client-address] A request's caller address could not be resolved safely; rejecting with 503.";
|
||||||
|
|
||||||
|
function sanitizedClientAddressUnresolvedResponse(): Response {
|
||||||
|
return new Response(JSON.stringify({ code: "CLIENT_ADDRESS_UNRESOLVED" }), {
|
||||||
|
headers: { "cache-control": "no-store", "content-type": "application/json" },
|
||||||
|
status: 503,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a `@hono/node-server` fetch callback at the exact Node/API transport boundary — the one
|
||||||
|
* place in this repository below `apps/api` that reads the raw Node `IncomingMessage` — with
|
||||||
|
* Core's framework-agnostic client-address resolution policy (`@ekklesia/core/identity`). Resolves
|
||||||
|
* the caller's trusted address from the real socket peer (`env.incoming.socket.remoteAddress`)
|
||||||
|
* before the wrapped callback — and therefore Better Auth's own rate limiter — ever sees the
|
||||||
|
* request, then overwrites (never merely appends) any externally supplied copy of
|
||||||
|
* `TRUSTED_CLIENT_ADDRESS_HEADER` with that resolved value. A caller can therefore never choose its
|
||||||
|
* own resolved address, whether by spoofing a forwarded header or by sending the internal header
|
||||||
|
* directly. Resolution failure never reaches the wrapped callback at all — no Better Auth logic, no
|
||||||
|
* Core mutation, and no module handler ever runs for a request whose caller address could not be
|
||||||
|
* resolved safely.
|
||||||
|
*/
|
||||||
|
export function wrapFetchWithClientAddress(
|
||||||
|
fetchCallback: (request: Request, env: NodeServerBindings) => Response | Promise<Response>,
|
||||||
|
resolver: ClientAddressResolver,
|
||||||
|
): (request: Request, env: NodeServerBindings) => Promise<Response> {
|
||||||
|
return async (request: Request, env: NodeServerBindings): Promise<Response> => {
|
||||||
|
const resolution = resolver.resolve({
|
||||||
|
headers: request.headers,
|
||||||
|
peerAddress: env.incoming.socket.remoteAddress,
|
||||||
|
});
|
||||||
|
if (!resolution.ok) {
|
||||||
|
console.error(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
|
||||||
|
return sanitizedClientAddressUnresolvedResponse();
|
||||||
|
}
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.set(TRUSTED_CLIENT_ADDRESS_HEADER, resolution.address);
|
||||||
|
const sanitizedRequest = new Request(request, { headers });
|
||||||
|
return fetchCallback(sanitizedRequest, env);
|
||||||
|
};
|
||||||
|
}
|
||||||
+86
-1
@@ -1,9 +1,30 @@
|
|||||||
|
import {
|
||||||
|
assertProductionDatabaseTransport,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
type DatabaseTransportPolicyInput,
|
||||||
|
} from "@ekklesia/core/database";
|
||||||
import {
|
import {
|
||||||
type CreateIdentityConfigOptions,
|
type CreateIdentityConfigOptions,
|
||||||
createIdentityConfig,
|
createIdentityConfig,
|
||||||
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 {
|
||||||
|
type ClientAddressConfig,
|
||||||
|
type ClientAddressConfigInput,
|
||||||
|
createClientAddressConfig,
|
||||||
|
} from "./client-address.js";
|
||||||
|
|
||||||
export type ApiConfigErrorCode =
|
export type ApiConfigErrorCode =
|
||||||
| "DATABASE_URL_INVALID"
|
| "DATABASE_URL_INVALID"
|
||||||
@@ -37,17 +58,52 @@ const defaultHost = "127.0.0.1";
|
|||||||
const defaultPort = 3000;
|
const defaultPort = 3000;
|
||||||
|
|
||||||
export interface ApiRuntimeConfig {
|
export interface ApiRuntimeConfig {
|
||||||
|
readonly clientAddress: ClientAddressConfig;
|
||||||
readonly databaseUrl: string;
|
readonly databaseUrl: string;
|
||||||
|
/** The one validated PostgreSQL transport policy this runtime's single pool uses. */
|
||||||
|
readonly databaseTransport: DatabaseTransportPolicy;
|
||||||
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
|
||||||
|
* trust unless explicitly configured. See `./client-address.js`. */
|
||||||
|
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||||
readonly databaseUrl?: unknown;
|
readonly databaseUrl?: unknown;
|
||||||
|
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
|
||||||
|
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
|
||||||
|
readonly databaseTransport?: DatabaseTransportPolicyInput;
|
||||||
readonly host?: unknown;
|
readonly 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 {
|
||||||
@@ -112,6 +168,35 @@ export function createApiRuntimeConfig(
|
|||||||
input.identity,
|
input.identity,
|
||||||
options.environment === undefined ? {} : { environment: options.environment },
|
options.environment === undefined ? {} : { environment: options.environment },
|
||||||
);
|
);
|
||||||
|
const clientAddress = createClientAddressConfig(input.clientAddress);
|
||||||
|
const environment = options.environment ?? "production";
|
||||||
|
const databaseTransport = createDatabaseTransportPolicy(input.databaseTransport, {
|
||||||
|
environment,
|
||||||
|
});
|
||||||
|
// Fails here — before `startApi` constructs a pool, Better Auth, the module runtime, or a
|
||||||
|
// listener — if a production deployment would connect without certificate-verified TLS, or if
|
||||||
|
// the URL carries TLS parameters that could override the policy.
|
||||||
|
assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment);
|
||||||
|
|
||||||
return Object.freeze({ databaseUrl, host, identity, port });
|
// Validated in the same pass as every other runtime value, before `startApi` constructs a pool,
|
||||||
|
// a provider client, or a listener — so a malformed endpoint or a half-supplied credential pair
|
||||||
|
// fails startup with a sanitized code rather than surfacing later as a confusing storage error.
|
||||||
|
const storage = createStorageProviderConfig(input.storage, { environment });
|
||||||
|
|
||||||
|
// Validated in the same pass as everything else, so a malformed port, a half-supplied credential
|
||||||
|
// pair, or a production deployment that asked for plaintext SMTP fails startup with a sanitized
|
||||||
|
// code — rather than surfacing later as a confusing failure the first time something tries to
|
||||||
|
// send a password-reset email.
|
||||||
|
const mail = createMailProviderConfig(input.mail, { environment });
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
clientAddress,
|
||||||
|
databaseTransport,
|
||||||
|
databaseUrl,
|
||||||
|
host,
|
||||||
|
identity,
|
||||||
|
mail,
|
||||||
|
port,
|
||||||
|
storage,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import {
|
||||||
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_MODULES_UI_READ,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
DASHBOARD_SORT_ORDER_MAX,
|
||||||
|
DASHBOARD_SORT_ORDER_MIN,
|
||||||
|
type DashboardStore,
|
||||||
|
DashboardStoreError,
|
||||||
|
isValidDashboardIdentifier,
|
||||||
|
normalizeDashboardGroupLabel,
|
||||||
|
resolveDashboardNavigation,
|
||||||
|
} from "@ekklesia/core/dashboard";
|
||||||
|
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js";
|
||||||
|
|
||||||
|
export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||||
|
const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`;
|
||||||
|
const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module as the Dashboard presents it: the module's display name, plus the generic mount
|
||||||
|
* descriptor unchanged.
|
||||||
|
*
|
||||||
|
* The descriptor is **embedded rather than flattened**, and that is the layering made visible.
|
||||||
|
* `module` is exactly `HostModuleUiDescriptor` — the host-neutral answer to "what is mountable and
|
||||||
|
* how" (ADR 0026) — and `name` is the Dashboard's presentation concern layered on top. A standalone
|
||||||
|
* host consumes the inner object and never learns that Ekklesia has folders; Ekklesia's shell
|
||||||
|
* consumes the outer one. Neither contract is warped to serve the other, and there is one
|
||||||
|
* construction of the descriptor rather than two that could drift.
|
||||||
|
*/
|
||||||
|
export interface DashboardNavigationModuleView {
|
||||||
|
readonly name: string;
|
||||||
|
readonly module: HostModuleUiDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationGroupView {
|
||||||
|
/** The stable key, unchanged by a rename. Addresses the group in the mutation API; never displayed. */
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly order: number;
|
||||||
|
readonly modules: readonly DashboardNavigationModuleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNavigationResponse {
|
||||||
|
readonly groups: readonly DashboardNavigationGroupView[];
|
||||||
|
/** Modules an administrator placed outside every group, and modules never configured at all — a
|
||||||
|
* newly installed module most importantly. Never empty merely because nothing has been arranged. */
|
||||||
|
readonly ungrouped: readonly DashboardNavigationModuleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDashboardRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The same publication registry the artifact route and the generic inventory serve from: the one
|
||||||
|
* accepted, mountable module set. Navigation never rescans, never reads a manifest, and never
|
||||||
|
* discovers anything of its own. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
readonly store: DashboardStore;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. A
|
||||||
|
* validation failure the route already rejects can still arrive here from a concurrent change, so
|
||||||
|
* both paths are handled rather than assumed unreachable. */
|
||||||
|
function storeFailure(c: Context, error: unknown): Response {
|
||||||
|
if (error instanceof DashboardStoreError) {
|
||||||
|
if (error.code === "DASHBOARD_GROUP_NOT_FOUND") {
|
||||||
|
return sanitizedFailure(c, 404, error.code);
|
||||||
|
}
|
||||||
|
if (error.code === "DASHBOARD_READ_FAILED" || error.code === "DASHBOARD_WRITE_FAILED") {
|
||||||
|
return sanitizedFailure(c, 503, error.code);
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 503, "DASHBOARD_WRITE_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsedSortOrder(value: unknown): number | undefined {
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < DASHBOARD_SORT_ORDER_MIN ||
|
||||||
|
value > DASHBOARD_SORT_ORDER_MAX
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_BODY_KEYS = new Set(["label", "order"]);
|
||||||
|
const PLACEMENT_BODY_KEYS = new Set(["groupId", "order"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured, closed validation — never a free-form configuration blob. Any key beyond the declared
|
||||||
|
* ones makes the whole body invalid rather than being silently dropped, so a caller cannot smuggle a
|
||||||
|
* field a future version might interpret, and a typo fails loudly instead of quietly doing nothing.
|
||||||
|
*/
|
||||||
|
function parseGroupBody(raw: unknown): { label: string; order: number } | undefined {
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !GROUP_BODY_KEYS.has(key))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const order = parsedSortOrder(raw.order);
|
||||||
|
const label = normalizeDashboardGroupLabel(raw.label);
|
||||||
|
if (label === undefined || order === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { label, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlacementBody(raw: unknown): { groupId: string | null; order: number } | undefined {
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !PLACEMENT_BODY_KEYS.has(key))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const order = parsedSortOrder(raw.order);
|
||||||
|
const groupId = raw.groupId;
|
||||||
|
if (order === undefined || (groupId !== null && !isValidDashboardIdentifier(groupId))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { groupId, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(c: Context): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await c.req.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Ekklesia Dashboard's navigation: how this installation presents its mountable modules, and the
|
||||||
|
* narrow, structured API for changing that.
|
||||||
|
*
|
||||||
|
* ## Navigation is layered on the inventory, not merged into it
|
||||||
|
*
|
||||||
|
* `GET /api/v1/module-ui` (ADR 0026) answers *what may be mounted* and stays host-neutral — a
|
||||||
|
* standalone host uses it and never learns Ekklesia has folders. This route answers *how this
|
||||||
|
* Dashboard presents those modules*, and embeds each generic descriptor untouched inside a
|
||||||
|
* presentation wrapper. Keeping them separate is what stops Dashboard semantics leaking into the
|
||||||
|
* portable module-host contract.
|
||||||
|
*
|
||||||
|
* ## Grouping is presentation, and structurally cannot be anything else
|
||||||
|
*
|
||||||
|
* The resolved view is computed by `resolveDashboardNavigation`, a pure function of stored
|
||||||
|
* configuration and the current module set. It reads no manifest, no dependency, no capability, and
|
||||||
|
* no entrypoint, and it produces no relationship between two modules. Modules stay flat (ADR 0021,
|
||||||
|
* ADR 0027): two modules in one group are exactly as related as two in different groups, and moving
|
||||||
|
* one changes a single presentation row — never its artifact, its API root, its permissions, its
|
||||||
|
* dependencies, or when it starts.
|
||||||
|
*
|
||||||
|
* ## Reading versus organizing
|
||||||
|
*
|
||||||
|
* Reading requires `core.modules.ui.read`, the same permission the generic inventory requires,
|
||||||
|
* because this response *contains* that inventory and serves the same audience: any signed-in user
|
||||||
|
* needs it to see anything at all. Organizing requires `core.dashboard.navigation.manage`
|
||||||
|
* (restricted) — a platform permission, never a module's — and, like every other Core mutation, an
|
||||||
|
* exact trusted `Origin` and a bounded body before authorization work begins.
|
||||||
|
*
|
||||||
|
* Holding the manage permission grants no module authority. Every module route remains enforced
|
||||||
|
* against its own declared permission, and no arrangement of folders changes a single authorization
|
||||||
|
* decision.
|
||||||
|
*/
|
||||||
|
export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const middlewareOptions: AuthorizationMiddlewareOptions = {
|
||||||
|
evaluator: options.evaluator,
|
||||||
|
registry: options.registry,
|
||||||
|
runtime: options.runtime,
|
||||||
|
};
|
||||||
|
const requireRead = requirePermission(middlewareOptions, CORE_MODULES_UI_READ);
|
||||||
|
const requireManage = requirePermission(middlewareOptions, CORE_DASHBOARD_NAVIGATION_MANAGE);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_NAVIGATION_PATH, requireRead, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
// Per-principal: the embedded descriptors carry permission hints evaluated for this caller.
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
try {
|
||||||
|
const [configuration, descriptors] = await Promise.all([
|
||||||
|
options.store.readConfiguration(),
|
||||||
|
describeMountableModules(options.evaluator, principal, options.moduleUi),
|
||||||
|
]);
|
||||||
|
const view = resolveDashboardNavigation(
|
||||||
|
configuration,
|
||||||
|
descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })),
|
||||||
|
);
|
||||||
|
const present = (entry: {
|
||||||
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
|
readonly name: string;
|
||||||
|
}): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name });
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
groups: view.groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
label: group.label,
|
||||||
|
modules: group.modules.map(present),
|
||||||
|
order: group.order,
|
||||||
|
})),
|
||||||
|
ungrouped: view.ungrouped.map(present),
|
||||||
|
} satisfies DashboardNavigationResponse,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DashboardStoreError) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
// An authorization-evaluation outage, exactly as the generic inventory reports it: a
|
||||||
|
// permission hint that could not be determined is never rendered as "not granted".
|
||||||
|
return sanitizedFailure(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
`${DASHBOARD_GROUPS_PATH}/:groupId`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const groupId = c.req.param("groupId");
|
||||||
|
const body = parseGroupBody(await readJsonBody(c));
|
||||||
|
if (!body || !isValidDashboardIdentifier(groupId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Idempotent by design: creating and renaming are the same operation against a
|
||||||
|
// client-chosen, stable id, so a rename can never orphan the placements pointing at it.
|
||||||
|
const group = await options.store.upsertGroup({
|
||||||
|
id: groupId,
|
||||||
|
label: body.label,
|
||||||
|
sortOrder: body.order,
|
||||||
|
});
|
||||||
|
return c.json({ id: group.id, label: group.label, order: group.sortOrder }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.delete(`${DASHBOARD_GROUPS_PATH}/:groupId`, requireOrigin, requireManage, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const groupId = c.req.param("groupId");
|
||||||
|
if (!isValidDashboardIdentifier(groupId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Modules in the group become ungrouped through the schema's `on delete set null`, so no
|
||||||
|
// module is lost and no placement is left pointing at a group that no longer exists.
|
||||||
|
await options.store.deleteGroup(groupId);
|
||||||
|
return c.body(null, 204);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
`${DASHBOARD_PLACEMENTS_PATH}/:moduleId`,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const body = parsePlacementBody(await readJsonBody(c));
|
||||||
|
if (!body || !isValidDashboardIdentifier(moduleId)) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// A placement may be stored for a module that is not installed: that is how uninstalling
|
||||||
|
// and reinstalling preserves an arrangement. Nothing renders it until the module is back.
|
||||||
|
const placement = await options.store.setPlacement({
|
||||||
|
groupId: body.groupId,
|
||||||
|
moduleId,
|
||||||
|
sortOrder: body.order,
|
||||||
|
});
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
groupId: placement.groupId,
|
||||||
|
moduleId: placement.moduleId,
|
||||||
|
order: placement.sortOrder,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -1,26 +1,47 @@
|
|||||||
|
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||||
import {
|
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 {
|
||||||
|
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 moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||||
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||||
/** 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes the one physical PostgreSQL pool this process uses for both Core's identity and
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||||
* authorization schemas: one validated, timeout-bounded `pg.Pool` (`createValidatedPostgresPool`,
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
* `@ekklesia/core/database`), wrapped by two independent typed Drizzle views
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
||||||
* (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Neither Core domain owns or
|
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||||
* closes the pool in this composition — `close()` here is the only thing that ever calls
|
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`,
|
||||||
* `pool.end()`. `createIdentityDrizzleDatabase`/`createAuthorizationDrizzleDatabase` remain
|
* `createModuleDatabaseView`). No domain
|
||||||
* available, unaffected, and independently testable for callers that only need one domain.
|
* owns or closes the pool in this composition — `close()`
|
||||||
|
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||||
|
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||||
|
* for callers that only need one domain.
|
||||||
*/
|
*/
|
||||||
export function createApiDatabase(
|
export function createApiDatabase(
|
||||||
databaseUrl: string,
|
databaseUrl: string,
|
||||||
@@ -31,6 +52,11 @@ 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),
|
||||||
|
moduleConfiguration: createModuleConfigurationView(pool),
|
||||||
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
-37
@@ -1,16 +1,17 @@
|
|||||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
import type { EkklesiaUserId, GrantId, PermissionId } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
createUserActor,
|
createUserActor,
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
safeParseAuthorizationScope,
|
|
||||||
safeParseEkklesiaUserId,
|
safeParseEkklesiaUserId,
|
||||||
safeParseGrantId,
|
safeParseGrantId,
|
||||||
safeParsePermissionId,
|
safeParsePermissionId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
|
AUTHORIZATION_GRANT_LIST_MAX_LIMIT,
|
||||||
type AuthorizationStore,
|
type AuthorizationStore,
|
||||||
AuthorizationStoreError,
|
AuthorizationStoreError,
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
type GrantPageBoundary,
|
||||||
type GrantRecord,
|
type GrantRecord,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
@@ -21,17 +22,41 @@ import {
|
|||||||
requirePermission,
|
requirePermission,
|
||||||
requireTrustedOrigin,
|
requireTrustedOrigin,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
|
||||||
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
|
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
readonly store: AuthorizationStore;
|
readonly store: AuthorizationStore;
|
||||||
readonly trustedOrigins: ReadonlySet<string>;
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
/** Injected only so expiration validation is deterministic under test. Production composition
|
||||||
|
* leaves this unset and the routes read real time. */
|
||||||
|
readonly now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LIST_LIMIT = 50;
|
||||||
|
|
||||||
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
if (error instanceof AuthorizationStoreError) {
|
if (error instanceof AuthorizationStoreError) {
|
||||||
const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503;
|
if (error.code === "AUTHORIZATION_GRANT_NOT_FOUND") {
|
||||||
return c.json({ code: error.code }, status);
|
return c.json({ code: error.code }, 404);
|
||||||
|
}
|
||||||
|
// The one conflict the caller can act on. It deliberately reveals nothing about the grant that
|
||||||
|
// already exists — not its id, actor, scope, source reference, or expiry — only that an
|
||||||
|
// equivalent authorization is already effective for this subject.
|
||||||
|
if (error.code === "AUTHORIZATION_GRANT_ALREADY_EXISTS") {
|
||||||
|
return c.json({ code: error.code }, 409);
|
||||||
|
}
|
||||||
|
// Defense in depth: the routes below reject both of these before ever reaching the store, so
|
||||||
|
// arriving here means a request-shaped problem slipped through. It is still a client input
|
||||||
|
// failure, never an infrastructure one, and it reports exactly like any other malformed
|
||||||
|
// request rather than leaking the store's internal distinction.
|
||||||
|
if (
|
||||||
|
error.code === "AUTHORIZATION_GRANT_EXPIRATION_INVALID" ||
|
||||||
|
error.code === "AUTHORIZATION_GRANT_LIMIT_INVALID"
|
||||||
|
) {
|
||||||
|
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ code: error.code }, 503);
|
||||||
}
|
}
|
||||||
return c.json({ code: "INTERNAL_ERROR" }, 503);
|
return c.json({ code: "INTERNAL_ERROR" }, 503);
|
||||||
}
|
}
|
||||||
@@ -39,20 +64,55 @@ function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
|||||||
interface ParsedCreateGrantBody {
|
interface ParsedCreateGrantBody {
|
||||||
readonly expiresAt?: Date;
|
readonly expiresAt?: Date;
|
||||||
readonly permission: PermissionId;
|
readonly permission: PermissionId;
|
||||||
readonly scope?: AuthorizationScope;
|
|
||||||
readonly subjectUserId: EkklesiaUserId;
|
readonly subjectUserId: EkklesiaUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "scope", "expiresAt"]);
|
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "expiresAt"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accepts only `subjectUserId`, `permission`, an optional `scope`, and an optional `expiresAt`.
|
* The one accepted expiration representation: `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to
|
||||||
* Any other key present — `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`,
|
* the parsed `Date`'s own `toISOString()`.
|
||||||
* `revokedAt`, a grant id, or anything claiming owner/effective-permission state — makes the
|
*
|
||||||
* whole body invalid rather than being silently dropped, so a caller cannot smuggle
|
* `new Date(value)` alone was far too permissive. It silently *normalized* impossible dates
|
||||||
* server-derived fields through a request that otherwise looks well-formed.
|
* (`2026-02-31T00:00:00.000Z` became March 3rd) and accepted locale-shaped input
|
||||||
|
* (`"March 5, 2027"`), date-only values, offsets, and missing milliseconds — so what an operator
|
||||||
|
* typed and what the platform stored could differ without any error. The regular expression pins
|
||||||
|
* the lexical form and the round-trip comparison rejects every value the parser would otherwise
|
||||||
|
* quietly rewrite.
|
||||||
*/
|
*/
|
||||||
function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
|
const CANONICAL_INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
||||||
|
|
||||||
|
function parseCanonicalFutureInstant(value: unknown, now: Date): Date | undefined {
|
||||||
|
if (typeof value !== "string" || !CANONICAL_INSTANT_PATTERN.test(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Strictly future: an expiration equal to or before the acceptance clock produces a grant that
|
||||||
|
// is already dead, which is never what the caller meant.
|
||||||
|
if (parsed.getTime() <= now.getTime()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts only `subjectUserId`, `permission`, and an optional `expiresAt`. Any other key present —
|
||||||
|
* `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`, `revokedAt`, a grant id, or
|
||||||
|
* anything claiming owner/effective-permission state — makes the whole body invalid rather than
|
||||||
|
* being silently dropped, so a caller cannot smuggle server-derived fields through a request that
|
||||||
|
* otherwise looks well-formed.
|
||||||
|
*
|
||||||
|
* **`scope` is deliberately among the rejected keys**, including when supplied as `null`, empty, or
|
||||||
|
* an otherwise valid scope string. Core's scoped-grant semantics remain intact for trusted internal
|
||||||
|
* callers, but no HTTP route middleware supplies a scope to the evaluator today, so a scope
|
||||||
|
* accepted here would produce a grant that looks meaningful, persists, appears in listings, and can
|
||||||
|
* authorize nothing at all. Accepting it would be offering a switch that is not connected to
|
||||||
|
* anything. When a server-owned resolver exists, this is the one place that changes.
|
||||||
|
*/
|
||||||
|
function parseCreateGrantBody(raw: unknown, now: Date): ParsedCreateGrantBody | undefined {
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -70,31 +130,138 @@ function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let scope: AuthorizationScope | undefined;
|
|
||||||
if (body.scope !== undefined) {
|
|
||||||
const parsedScope = safeParseAuthorizationScope(body.scope);
|
|
||||||
if (!parsedScope.success) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
scope = parsedScope.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
let expiresAt: Date | undefined;
|
let expiresAt: Date | undefined;
|
||||||
if (body.expiresAt !== undefined) {
|
if (body.expiresAt !== undefined) {
|
||||||
if (typeof body.expiresAt !== "string") {
|
expiresAt = parseCanonicalFutureInstant(body.expiresAt, now);
|
||||||
|
if (expiresAt === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const parsedDate = new Date(body.expiresAt);
|
|
||||||
if (Number.isNaN(parsedDate.getTime())) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
expiresAt = parsedDate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||||
permission: permission.data,
|
permission: permission.data,
|
||||||
...(scope === undefined ? {} : { scope }),
|
subjectUserId: subjectUserId.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURSOR_VERSION = 1;
|
||||||
|
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An opaque pagination *position*, not an authorization token. It carries only the subject user id,
|
||||||
|
* the boundary row's creation instant, and the boundary grant id — never a permission, scope,
|
||||||
|
* source reference, actor, credential, or secret — so there is nothing in it worth protecting.
|
||||||
|
*
|
||||||
|
* It is deliberately unsigned. Signing would imply the cursor carries authority it does not have:
|
||||||
|
* the permission check and the server-side subject filter remain authoritative on every page, and
|
||||||
|
* a forged cursor can at most name a different position within grants the caller is already
|
||||||
|
* entitled to list. The strict decode below still refuses anything that is not exactly what this
|
||||||
|
* function would emit.
|
||||||
|
*/
|
||||||
|
function encodeCursor(userId: EkklesiaUserId, boundary: GrantPageBoundary): string {
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
g: boundary.id,
|
||||||
|
t: boundary.createdAt.toISOString(),
|
||||||
|
u: userId,
|
||||||
|
v: CURSOR_VERSION,
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict in every dimension: canonical unpadded base64url, valid UTF-8 JSON, an object with exactly
|
||||||
|
* the four expected keys, the exact version, a subject matching the *requested* one, a canonical
|
||||||
|
* `toISOString()` timestamp, a canonical `GrantId`, and — the catch-all — a re-encoding that
|
||||||
|
* reproduces the supplied string byte-for-byte, which rejects padding, trailing data, and any
|
||||||
|
* alternative encoding of the same payload. A rejected cursor reveals nothing it decoded.
|
||||||
|
*/
|
||||||
|
function decodeCursor(raw: string, userId: EkklesiaUserId): GrantPageBoundary | undefined {
|
||||||
|
if (!BASE64URL_PATTERN.test(raw)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const payload = parsed as Record<string, unknown>;
|
||||||
|
if (Object.keys(payload).sort().join(",") !== "g,t,u,v") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (payload.v !== CURSOR_VERSION || payload.u !== userId || typeof payload.t !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const createdAt = new Date(payload.t);
|
||||||
|
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== payload.t) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const id = safeParseGrantId(payload.g);
|
||||||
|
if (!id.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const boundary: GrantPageBoundary = Object.freeze({ createdAt, id: id.data });
|
||||||
|
if (encodeCursor(userId, boundary) !== raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return boundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_ALLOWED_QUERY_KEYS = new Set(["userId", "limit", "cursor"]);
|
||||||
|
/** Canonical base-10, no sign, no leading zero, no decimal point, no whitespace, no exponent. */
|
||||||
|
const CANONICAL_POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
|
||||||
|
|
||||||
|
interface ParsedListQuery {
|
||||||
|
readonly after?: GrantPageBoundary;
|
||||||
|
readonly limit: number;
|
||||||
|
readonly subjectUserId: EkklesiaUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fully validated before any store or database work happens, so an invalid page request never
|
||||||
|
* becomes a query, and never becomes a way to probe which subjects or grants exist. */
|
||||||
|
function parseListQuery(url: string): ParsedListQuery | undefined {
|
||||||
|
const searchParams = new URL(url).searchParams;
|
||||||
|
for (const key of searchParams.keys()) {
|
||||||
|
if (!LIST_ALLOWED_QUERY_KEYS.has(key) || searchParams.getAll(key).length > 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectUserId = safeParseEkklesiaUserId(searchParams.get("userId"));
|
||||||
|
if (!subjectUserId.success) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = DEFAULT_LIST_LIMIT;
|
||||||
|
const rawLimit = searchParams.get("limit");
|
||||||
|
if (rawLimit !== null) {
|
||||||
|
if (!CANONICAL_POSITIVE_INTEGER_PATTERN.test(rawLimit)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
limit = Number(rawLimit);
|
||||||
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let after: GrantPageBoundary | undefined;
|
||||||
|
const rawCursor = searchParams.get("cursor");
|
||||||
|
if (rawCursor !== null) {
|
||||||
|
after = decodeCursor(rawCursor, subjectUserId.data);
|
||||||
|
if (after === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
...(after === undefined ? {} : { after }),
|
||||||
|
limit,
|
||||||
subjectUserId: subjectUserId.data,
|
subjectUserId: subjectUserId.data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,29 +291,55 @@ function serializeGrant(grant: GrantRecord) {
|
|||||||
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
|
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
|
||||||
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
|
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
|
||||||
* those grants remain owned by the module operation that established the relationship.
|
* those grants remain owned by the module operation that established the relationship.
|
||||||
|
*
|
||||||
|
* Creation is unscoped-only; see `parseCreateGrantBody`.
|
||||||
*/
|
*/
|
||||||
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
const now = options.now ?? ((): Date => new Date());
|
||||||
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
|
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
|
||||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
// Origin is checked first (a rejected cross-origin mutation must never have its body read), then
|
||||||
|
// the body-size limit runs before permission evaluation, so an oversized mutation never reaches
|
||||||
|
// authorization work or the store.
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
|
||||||
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
|
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId"));
|
// Reached only after `requireManagePermission` allowed the caller, so query validation can
|
||||||
if (!subjectUserId.success) {
|
// never disclose a validation distinction — or the existence of any subject or grant — to an
|
||||||
|
// unauthorized caller.
|
||||||
|
const query = parseListQuery(c.req.url);
|
||||||
|
if (!query) {
|
||||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const grants = await options.store.listGrantsForUser(subjectUserId.data, {
|
const page = await options.store.listGrantsForUser(query.subjectUserId, {
|
||||||
|
...(query.after === undefined ? {} : { after: query.after }),
|
||||||
|
limit: query.limit,
|
||||||
sourceKind: "explicit",
|
sourceKind: "explicit",
|
||||||
});
|
});
|
||||||
return c.json({ grants: grants.map(serializeGrant) }, 200);
|
return c.json(
|
||||||
|
{
|
||||||
|
grants: page.grants.map(serializeGrant),
|
||||||
|
nextCursor:
|
||||||
|
page.nextBoundary === undefined
|
||||||
|
? null
|
||||||
|
: encodeCursor(query.subjectUserId, page.nextBoundary),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sanitizedStoreErrorResponse(c, error);
|
return sanitizedStoreErrorResponse(c, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => {
|
routes.post(
|
||||||
|
"/api/v1/grants",
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManagePermission,
|
||||||
|
async (c) => {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
const principal = getAuthorizedPrincipal(c);
|
const principal = getAuthorizedPrincipal(c);
|
||||||
if (!isAuthenticatedPrincipal(principal)) {
|
if (!isAuthenticatedPrincipal(principal)) {
|
||||||
@@ -159,7 +352,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
|||||||
} catch {
|
} catch {
|
||||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||||
}
|
}
|
||||||
const parsed = parseCreateGrantBody(rawBody);
|
const parsed = parseCreateGrantBody(rawBody, now());
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||||
}
|
}
|
||||||
@@ -179,7 +372,6 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
|||||||
actor: createUserActor(principal.userId),
|
actor: createUserActor(principal.userId),
|
||||||
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
|
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
|
||||||
permission: parsed.permission,
|
permission: parsed.permission,
|
||||||
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
|
|
||||||
sourceKind: "explicit",
|
sourceKind: "explicit",
|
||||||
userId: parsed.subjectUserId,
|
userId: parsed.subjectUserId,
|
||||||
});
|
});
|
||||||
@@ -187,11 +379,13 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sanitizedStoreErrorResponse(c, error);
|
return sanitizedStoreErrorResponse(c, error);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
routes.post(
|
routes.post(
|
||||||
"/api/v1/grants/:grantId/revoke",
|
"/api/v1/grants/:grantId/revoke",
|
||||||
requireOrigin,
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
requireManagePermission,
|
requireManagePermission,
|
||||||
async (c) => {
|
async (c) => {
|
||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
@@ -200,6 +394,9 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
|||||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical-UUID validation happens after authorization but strictly before any store or
|
||||||
|
// database call: a malformed id previously reached PostgreSQL's own UUID comparison and came
|
||||||
|
// back as an indistinguishable infrastructure failure (503) rather than a request problem.
|
||||||
const grantId = safeParseGrantId(c.req.param("grantId"));
|
const grantId = safeParseGrantId(c.req.param("grantId"));
|
||||||
if (!grantId.success) {
|
if (!grantId.success) {
|
||||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||||
@@ -208,7 +405,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
|||||||
try {
|
try {
|
||||||
await options.store.revokeGrant({
|
await options.store.revokeGrant({
|
||||||
actor: createUserActor(principal.userId),
|
actor: createUserActor(principal.userId),
|
||||||
grantId: grantId.data,
|
grantId: grantId.data satisfies GrantId,
|
||||||
sourceKind: "explicit",
|
sourceKind: "explicit",
|
||||||
});
|
});
|
||||||
return c.json({ success: true }, 200);
|
return c.json({ success: true }, 200);
|
||||||
|
|||||||
@@ -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,301 @@
|
|||||||
|
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 { 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,8 +1,16 @@
|
|||||||
import type {
|
import {
|
||||||
ModuleId,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
ModuleRouteHandlerContext,
|
EMPTY_MODULE_STORAGE,
|
||||||
RegisteredModuleHttpRoute,
|
type ModuleId,
|
||||||
|
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,
|
||||||
@@ -10,6 +18,7 @@ import {
|
|||||||
requirePermission,
|
requirePermission,
|
||||||
requireTrustedOrigin,
|
requireTrustedOrigin,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
|
import { platformBodyLimit } from "./body-limits.js";
|
||||||
|
|
||||||
const MODULE_ROUTE_PREFIX = "/api/v1/modules";
|
const MODULE_ROUTE_PREFIX = "/api/v1/modules";
|
||||||
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]);
|
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]);
|
||||||
@@ -24,6 +33,32 @@ const STRIPPED_CORS_RESPONSE_HEADERS = [
|
|||||||
|
|
||||||
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
readonly trustedOrigins: ReadonlySet<string>;
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
/** The same frozen services object the started module runtime's `start`/`stop` hooks already
|
||||||
|
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||||
|
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||||
|
* after authorization succeeds. */
|
||||||
|
readonly services: 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
|
||||||
@@ -38,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 {
|
||||||
@@ -53,15 +105,15 @@ function sanitizedModuleHandlerFailure(c: Context): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `Vary` lists header *names*, which are themselves case-insensitive, so merging de-duplicates
|
* The one shared `Vary` parsing/canonicalization primitive: splits and trims comma-separated
|
||||||
* case-insensitively — a module's own `origin` must collapse with the forced `Origin` rather than
|
* tokens and de-duplicates them case-insensitively — `Vary` lists header *names*, which are
|
||||||
* appear as a separate, redundant entry — and always emits the canonical `Origin`/`Cookie` casing
|
* themselves case-insensitive, so a module's own `origin` must collapse with a later `Origin`
|
||||||
* regardless of how (or whether) a module already listed them. A literal `*` (RFC 9110 §12.5.5)
|
* rather than appear as a separate, redundant entry — while preserving first-seen casing and
|
||||||
* must never be combined with any other value; since `*` already means "may vary on anything," a
|
* relative order. Never returns early on `*`; callers decide what a `*` token means for their own
|
||||||
* strictly broader signal than any explicit list, an existing `*` collapses the result to `*` alone
|
* policy (see `mergedVaryHeader` and `canonicalizeVaryHeader` below, the two callers that build on
|
||||||
* rather than producing the invalid `*, Origin, Cookie`.
|
* this instead of re-implementing parsing/dedup independently).
|
||||||
*/
|
*/
|
||||||
export function mergedVaryHeader(existingVary: string | null): string {
|
function parseVaryTokens(existingVary: string | null): Map<string, string> {
|
||||||
const canonicalByLowercase = new Map<string, string>();
|
const canonicalByLowercase = new Map<string, string>();
|
||||||
for (const token of (existingVary ?? "").split(",")) {
|
for (const token of (existingVary ?? "").split(",")) {
|
||||||
const trimmed = token.trim();
|
const trimmed = token.trim();
|
||||||
@@ -73,12 +125,46 @@ export function mergedVaryHeader(existingVary: string | null): string {
|
|||||||
canonicalByLowercase.set(key, trimmed);
|
canonicalByLowercase.set(key, trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canonicalByLowercase.has("*")) {
|
return canonicalByLowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A literal `*` (RFC 9110 §12.5.5) must never be combined with any other value; since `*` already
|
||||||
|
* means "may vary on anything," a strictly broader signal than any explicit list, its presence
|
||||||
|
* collapses the result to `*` alone rather than producing an invalid combination. */
|
||||||
|
function serializeVaryTokens(tokens: ReadonlyMap<string, string>): string {
|
||||||
|
if (tokens.has("*")) {
|
||||||
return "*";
|
return "*";
|
||||||
}
|
}
|
||||||
canonicalByLowercase.set("origin", "Origin");
|
return [...tokens.values()].join(", ");
|
||||||
canonicalByLowercase.set("cookie", "Cookie");
|
}
|
||||||
return [...canonicalByLowercase.values()].join(", ");
|
|
||||||
|
/**
|
||||||
|
* Used only for a module's own response: always emits the canonical `Origin`/`Cookie` casing
|
||||||
|
* regardless of how (or whether) a module already listed them, since every module response is
|
||||||
|
* forced to vary on both (see `sanitizeModuleResponse`) — this is a deliberate product policy for
|
||||||
|
* module responses specifically, not a generic property every response through the platform's CORS
|
||||||
|
* boundary should have forced onto it (an `OPTIONS` preflight, for one, has no `Cookie` concept at
|
||||||
|
* all).
|
||||||
|
*/
|
||||||
|
export function mergedVaryHeader(existingVary: string | null): string {
|
||||||
|
const tokens = parseVaryTokens(existingVary);
|
||||||
|
if (tokens.has("*")) {
|
||||||
|
return "*";
|
||||||
|
}
|
||||||
|
tokens.set("origin", "Origin");
|
||||||
|
tokens.set("cookie", "Cookie");
|
||||||
|
return serializeVaryTokens(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used at the app-level CORS boundary (`apps/api/src/app.ts`), which wraps every response —
|
||||||
|
* `OPTIONS` preflights and Core's own routes included, not only module ones — after Hono's own
|
||||||
|
* `cors()` has already appended its own `Vary: Origin`. Unlike `mergedVaryHeader`, this never
|
||||||
|
* injects a token that was not already present; it only de-duplicates and collapses `*`, since a
|
||||||
|
* generic boundary must not assume every response wants `Origin`/`Cookie` forced onto it.
|
||||||
|
*/
|
||||||
|
export function canonicalizeVaryHeader(existingVary: string): string {
|
||||||
|
return serializeVaryTokens(parseVaryTokens(existingVary));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,7 +201,12 @@ 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 — never a Hono `Context`, database, store, or configuration.
|
* frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`,
|
||||||
|
* `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or
|
||||||
|
* deployment-wide configuration. `services.database` is the module's only reachable database
|
||||||
|
* surface, and the whole context is unreachable to anything short of a successfully authorized
|
||||||
|
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
|
||||||
|
* this handler at all).
|
||||||
*
|
*
|
||||||
* Because the routes and registry both derive from the same discovered inventory
|
* Because the routes and registry both derive from the same discovered inventory
|
||||||
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
||||||
@@ -143,12 +234,23 @@ 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
|
||||||
|
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
|
||||||
|
// never reaches authorization work, let alone the handler or the module database. In
|
||||||
|
// `streamed` mode no body byte is read until the handler reads it, and the handler runs only
|
||||||
|
// after authorization succeeds, so an unauthorized caller can never push an upload through
|
||||||
|
// (ADR 0042).
|
||||||
|
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||||
const path = moduleRoutePath(route.moduleId, route.path);
|
const 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
@@ -167,9 +269,16 @@ export function mountModuleHttpRoutes(
|
|||||||
// array — each branch below is therefore a distinct, fixed-arity call rather than one call
|
// array — each branch below is therefore a distinct, fixed-arity call rather than one call
|
||||||
// spreading a variable-length handlers array.
|
// spreading a variable-length handlers array.
|
||||||
if (STATE_CHANGING_METHODS.has(route.method)) {
|
if (STATE_CHANGING_METHODS.has(route.method)) {
|
||||||
app.on(route.method, path, requireOrigin, requireRoutePermission, finalHandler);
|
app.on(
|
||||||
|
route.method,
|
||||||
|
path,
|
||||||
|
requireOrigin,
|
||||||
|
routeBodyLimit,
|
||||||
|
requireRoutePermission,
|
||||||
|
finalHandler,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
app.on(route.method, path, requireRoutePermission, finalHandler);
|
app.on(route.method, path, routeBodyLimit, requireRoutePermission, finalHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import type {
|
||||||
|
ModuleBlockedReason,
|
||||||
|
ModuleBlockingDependency,
|
||||||
|
ModuleRuntimeState,
|
||||||
|
ModuleSecretSource,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
type ModuleConfigurationStore,
|
||||||
|
type ModuleLifecycleSnapshot,
|
||||||
|
type ModuleStorageAvailability,
|
||||||
|
type ModuleLifecycleStore,
|
||||||
|
ModuleLifecycleStoreError,
|
||||||
|
type ModuleLifecycleView,
|
||||||
|
resolveModuleLifecycleView,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
requirePermission,
|
||||||
|
requireTrustedOrigin,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **sibling** of `/api/v1/modules/`, never a child of it — the same rule ADR 0025 established for
|
||||||
|
* `/api/v1/module-ui`, and for the same concrete reason. Every module HTTP route is mounted under
|
||||||
|
* `/api/v1/modules/<module-id>/<local path>`, and `moduleRoutePathSchema` accepts `enabled` as an
|
||||||
|
* ordinary static segment, so a module named `announcements` registering `PUT /enabled` would land
|
||||||
|
* on exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle
|
||||||
|
* mutation there would put Core and an installed module in one URL namespace with registration
|
||||||
|
* order deciding which one answers — for a route that changes whether modules run. A sibling prefix
|
||||||
|
* cannot collide.
|
||||||
|
*/
|
||||||
|
export const MODULE_LIFECYCLE_PATH = "/api/v1/module-lifecycle";
|
||||||
|
const MODULE_LIFECYCLE_ENABLED_PATH = `${MODULE_LIFECYCLE_PATH}/:moduleId/enabled`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module as an operator sees it. Two clearly separated axes, deliberately never merged into a
|
||||||
|
* single status word.
|
||||||
|
*
|
||||||
|
* `configured` is the decision that survives restarts. `runtime` is what *this process* did. They
|
||||||
|
* can legitimately disagree — Ekklesia does not hot-load or hot-unload modules — and
|
||||||
|
* `restartRequired` names that disagreement instead of hiding it behind a status that would be a
|
||||||
|
* lie in one direction or the other.
|
||||||
|
*
|
||||||
|
* Nothing here carries a filesystem path, a package name, a manifest path, a directory, an
|
||||||
|
* entrypoint, a caught error, or a stack. The only identifiers are module ids, capability ids, and
|
||||||
|
* a fixed reason code from a closed set — all of them values the platform itself produced and
|
||||||
|
* validated.
|
||||||
|
*/
|
||||||
|
export interface ModuleLifecycleModuleView {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly configured: {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
/** `false` means nobody has decided and the platform default (enabled) applies. */
|
||||||
|
readonly explicit: boolean;
|
||||||
|
};
|
||||||
|
readonly runtime: {
|
||||||
|
readonly state: ModuleRuntimeState;
|
||||||
|
readonly reason?: ModuleBlockedReason;
|
||||||
|
/** Present for a dependency reason. */
|
||||||
|
readonly dependency?: ModuleBlockingDependency;
|
||||||
|
/** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is
|
||||||
|
* declared in a public manifest; a value may be a credential or arbitrary operator text. */
|
||||||
|
readonly configurationKey?: string;
|
||||||
|
/** Present for a storage reason (ADR 0030): the logical bucket the module declared, never an
|
||||||
|
* endpoint, a credential, or the physical bucket the host maps it to. */
|
||||||
|
readonly storageBucketId?: string;
|
||||||
|
};
|
||||||
|
readonly restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleLifecycleResponse {
|
||||||
|
readonly modules: readonly ModuleLifecycleModuleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The boot snapshot: the discovered inventory this process started from, the eligibility it
|
||||||
|
* resolved, and the modules the runtime reports it actually started. Captured once at startup and
|
||||||
|
* never recomputed — this endpoint describes the platform that is running, not one that would run
|
||||||
|
* if it were restarted right now. */
|
||||||
|
readonly snapshot: ModuleLifecycleSnapshot;
|
||||||
|
readonly store: ModuleLifecycleStore;
|
||||||
|
/**
|
||||||
|
* How to re-read the *other* axis that decides eligibility (ADR 0029). Since a module's own
|
||||||
|
* configuration can block it, `restartRequired` must be computed against live configuration as
|
||||||
|
* well as live enablement — otherwise correcting a missing required value would report no pending
|
||||||
|
* restart for a module the next boot really will start.
|
||||||
|
*/
|
||||||
|
readonly configuration: {
|
||||||
|
readonly store: ModuleConfigurationStore;
|
||||||
|
readonly secrets: ModuleSecretSource;
|
||||||
|
};
|
||||||
|
/** The other axis that decides eligibility (ADR 0030). A boolean, never a probe: whether storage
|
||||||
|
* is *configured* decides eligibility, while whether it is *reachable right now* never does. */
|
||||||
|
readonly storage: ModuleStorageAvailability;
|
||||||
|
readonly trustedOrigins: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary, exactly as
|
||||||
|
* `dashboard-routes.ts` does. */
|
||||||
|
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
|
||||||
|
if (error instanceof ModuleLifecycleStoreError) {
|
||||||
|
if (
|
||||||
|
error.code === "MODULE_LIFECYCLE_READ_FAILED" ||
|
||||||
|
error.code === "MODULE_LIFECYCLE_WRITE_FAILED"
|
||||||
|
) {
|
||||||
|
return sanitizedFailure(c, 503, error.code);
|
||||||
|
}
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
return sanitizedFailure(
|
||||||
|
c,
|
||||||
|
503,
|
||||||
|
fallback === "read" ? "MODULE_LIFECYCLE_READ_FAILED" : "MODULE_LIFECYCLE_WRITE_FAILED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENABLED_BODY_KEYS = new Set(["enabled"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured, closed validation — never a configuration blob. Any key beyond `enabled` makes the
|
||||||
|
* whole body invalid rather than being silently dropped, so a caller cannot smuggle a field a
|
||||||
|
* future version might interpret, and a typo fails loudly instead of quietly doing nothing. Mirrors
|
||||||
|
* `parseGroupBody`/`parsePlacementBody` (`./dashboard-routes.js`) exactly.
|
||||||
|
*/
|
||||||
|
function parseEnabledBody(raw: unknown): { enabled: boolean } | undefined {
|
||||||
|
if (!isRecord(raw) || Object.keys(raw).some((key) => !ENABLED_BODY_KEYS.has(key))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(c: Context): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await c.req.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
|
||||||
|
return {
|
||||||
|
configured: { enabled: view.configured.enabled, explicit: view.configured.explicit },
|
||||||
|
id: view.moduleId,
|
||||||
|
restartRequired: view.restartRequired,
|
||||||
|
runtime: {
|
||||||
|
state: view.runtime.state,
|
||||||
|
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
|
||||||
|
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
|
||||||
|
...(view.runtime.configurationKey === undefined
|
||||||
|
? {}
|
||||||
|
: { configurationKey: view.runtime.configurationKey }),
|
||||||
|
...(view.runtime.storageBucketId === undefined
|
||||||
|
? {}
|
||||||
|
: { storageBucketId: view.runtime.storageBucketId }),
|
||||||
|
},
|
||||||
|
version: view.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's operational lifecycle resource: which installed modules are enabled, what this
|
||||||
|
* process actually did with each of them, and the narrow, structured API for changing that.
|
||||||
|
*
|
||||||
|
* ## Enablement is host configuration, and structurally cannot be anything else
|
||||||
|
*
|
||||||
|
* A module neither declares nor reads its own enabled state. Nothing in a manifest, a capability, a
|
||||||
|
* permission declaration, or a lifecycle hook can influence a value stored here, and a module is
|
||||||
|
* never told it is enabled — its host simply loads and starts it, or does not. That is what keeps
|
||||||
|
* the same module implementation reusable under a future standalone host, whose loading decisions
|
||||||
|
* are its own (ADR 0028).
|
||||||
|
*
|
||||||
|
* ## Changing configuration does not change the running process
|
||||||
|
*
|
||||||
|
* `PUT` writes a decision. It does not start, stop, load, unload, or reconfigure anything: there is
|
||||||
|
* no hot module installation or removal in this platform (ADR 0015, unchanged). The response
|
||||||
|
* therefore reports the new configuration *and* the unchanged runtime observation *and*
|
||||||
|
* `restartRequired`, rather than pretending the module stopped. Hot-unloading a module to make a
|
||||||
|
* button feel immediate would trade a real guarantee — that a module's ESM evaluation happens
|
||||||
|
* exactly once, and can never be rolled back — for a cosmetic one.
|
||||||
|
*
|
||||||
|
* ## Only a module this process can see may be configured
|
||||||
|
*
|
||||||
|
* `PUT` against an id that is not in the boot inventory is a `404`. Unlike an inert Dashboard
|
||||||
|
* placement, an enablement record is operational configuration, and accepting one for an arbitrary
|
||||||
|
* identifier would make this an unbounded write surface for a delegable permission. *Existing*
|
||||||
|
* records for modules that are no longer deployed are still retained and simply ignored, so
|
||||||
|
* redeploying a module restores the decision (see `resolveModuleEligibility`).
|
||||||
|
*
|
||||||
|
* ## Reading is administrative too
|
||||||
|
*
|
||||||
|
* Both verbs require `core.modules.lifecycle.manage` (restricted). This resource is the only place
|
||||||
|
* a disabled module, a blocked module, and the required-dependency edges between them are visible;
|
||||||
|
* `core.modules.ui.read` deliberately covers only what is mountable. Mutations additionally require
|
||||||
|
* an exact trusted `Origin` and a bounded body before authorization work begins, exactly like every
|
||||||
|
* other Core mutation.
|
||||||
|
*/
|
||||||
|
export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const requireManage = requirePermission(
|
||||||
|
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||||
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
|
);
|
||||||
|
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||||
|
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
|
||||||
|
const installedModuleIds = new Set(
|
||||||
|
options.snapshot.discoveredModules.map((module) => module.manifest.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(MODULE_LIFECYCLE_PATH, requireManage, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
// Not per-principal in content, but it is cookie-authorized, so it varies for the same reason
|
||||||
|
// every other authorized Core read does.
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
try {
|
||||||
|
const [configuration, settings] = await Promise.all([
|
||||||
|
options.store.readConfiguration(),
|
||||||
|
options.configuration.store.readSettings(),
|
||||||
|
]);
|
||||||
|
const modules = resolveModuleLifecycleView(
|
||||||
|
options.snapshot,
|
||||||
|
configuration,
|
||||||
|
{ secrets: options.configuration.secrets, settings },
|
||||||
|
options.storage,
|
||||||
|
).map(present);
|
||||||
|
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error, "read");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put(
|
||||||
|
MODULE_LIFECYCLE_ENABLED_PATH,
|
||||||
|
requireOrigin,
|
||||||
|
mutationBodyLimit,
|
||||||
|
requireManage,
|
||||||
|
async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const moduleId = c.req.param("moduleId");
|
||||||
|
const body = parseEnabledBody(await readJsonBody(c));
|
||||||
|
if (!body) {
|
||||||
|
return sanitizedFailure(c, 400, "INVALID_REQUEST");
|
||||||
|
}
|
||||||
|
if (!installedModuleIds.has(moduleId)) {
|
||||||
|
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.store.setEnabled({ enabled: body.enabled, moduleId });
|
||||||
|
// Re-read rather than predict: the response describes the configuration that is actually
|
||||||
|
// stored now, resolved against the same boot snapshot the collection read uses, so the two
|
||||||
|
// endpoints can never disagree about `restartRequired`.
|
||||||
|
const [configuration, settings] = await Promise.all([
|
||||||
|
options.store.readConfiguration(),
|
||||||
|
options.configuration.store.readSettings(),
|
||||||
|
]);
|
||||||
|
const view = resolveModuleLifecycleView(
|
||||||
|
options.snapshot,
|
||||||
|
configuration,
|
||||||
|
{ secrets: options.configuration.secrets, settings },
|
||||||
|
options.storage,
|
||||||
|
).find((entry) => entry.moduleId === moduleId);
|
||||||
|
if (!view) {
|
||||||
|
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
|
||||||
|
}
|
||||||
|
return c.json(present(view), 200);
|
||||||
|
} catch (error) {
|
||||||
|
return storeFailure(c, error, "write");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { ModuleRealtimeError, type ModuleId, type PermissionId } from "@ekklesia/contracts";
|
||||||
|
import type {
|
||||||
|
ModuleRealtimePublication,
|
||||||
|
ModuleRealtimeRegistry,
|
||||||
|
RegisteredModuleRealtimeChannel,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
requirePermission,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
|
||||||
|
const MODULE_REALTIME_PREFIX = "/api/v1/module-realtime";
|
||||||
|
|
||||||
|
/** Host-owned keepalive interval. Comments keep idle HTTP streams alive without entering module code,
|
||||||
|
* consuming a sequence, or becoming a browser-visible state update. */
|
||||||
|
export const MODULE_REALTIME_KEEPALIVE_MS = 15_000;
|
||||||
|
/** Forces a fresh cookie/permission evaluation periodically rather than letting an authorization
|
||||||
|
* decision live for an unbounded stream lifetime. EventSource reconnects after the clean EOF. */
|
||||||
|
export const MODULE_REALTIME_MAX_CONNECTION_AGE_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
export interface ModuleRealtimeTransportTiming {
|
||||||
|
/** Test-only narrowing of the production keepalive. Production composition never sets this. */
|
||||||
|
readonly keepaliveMs?: number;
|
||||||
|
/** Test-only narrowing of the production reauthorization interval. */
|
||||||
|
readonly maxConnectionAgeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleRealtimeRoutesOptions
|
||||||
|
extends AuthorizationMiddlewareOptions,
|
||||||
|
ModuleRealtimeTransportTiming {
|
||||||
|
readonly realtime: ModuleRealtimeRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server is the sole author of this deployment-private base. Module UI receives it through the
|
||||||
|
* host descriptor and never reconstructs the platform prefix or supplies its own module id. */
|
||||||
|
export function moduleRealtimeBaseUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_REALTIME_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleRealtimeChannelUrl(moduleId: ModuleId, channel: string): string {
|
||||||
|
return `${moduleRealtimeBaseUrl(moduleId)}/${encodeURIComponent(channel)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A forged runtime snapshot that could not be permission-gated is a composition failure. Carries no
|
||||||
|
* module/channel/permission value and is thrown before any route from that snapshot is usable. */
|
||||||
|
export class ModuleRealtimeConfigurationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("A published module realtime channel references a permission that is not registered.");
|
||||||
|
this.name = "ModuleRealtimeConfigurationError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function realtimeEnvelope(publication: ModuleRealtimePublication): string {
|
||||||
|
// `serializedPayload` was validated and serialized exactly once by Core. Concatenating it as the
|
||||||
|
// JSON value preserves every opaque string — including signed object-delivery URLs — byte for byte.
|
||||||
|
return `{"sequence":${publication.sequence},"payload":${publication.serializedPayload}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function realtimeEvent(
|
||||||
|
kind: "snapshot" | "update",
|
||||||
|
publication: ModuleRealtimePublication,
|
||||||
|
): string {
|
||||||
|
// Core's compact JSON serialization contains no literal CR/LF bytes inside strings, so one `data`
|
||||||
|
// line is sufficient. Deliberately no SSE `id`: this transport does not implement Last-Event-ID
|
||||||
|
// replay, and a process-local sequence must not be mistaken for a durable cursor.
|
||||||
|
return `event: ${kind}\ndata: ${realtimeEnvelope(publication)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedRealtimeFailure(c: Context, error: unknown): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
const code =
|
||||||
|
error instanceof ModuleRealtimeError && error.code === "REALTIME_SUBSCRIBER_LIMIT"
|
||||||
|
? "REALTIME_SUBSCRIBER_LIMIT"
|
||||||
|
: "REALTIME_UNAVAILABLE";
|
||||||
|
return c.json({ code }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CancelableDelay {
|
||||||
|
readonly promise: Promise<"keepalive">;
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepaliveDelay(milliseconds: number): CancelableDelay {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const promise = new Promise<"keepalive">((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve("keepalive"), milliseconds);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
cancel: () => clearTimeout(timer),
|
||||||
|
promise,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRealtimeStream(
|
||||||
|
definition: RegisteredModuleRealtimeChannel,
|
||||||
|
registry: ModuleRealtimeRegistry,
|
||||||
|
keepaliveMs: number,
|
||||||
|
maxConnectionAgeMs: number,
|
||||||
|
): ReadableStream<Uint8Array> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const subscription = registry.subscribe(definition.moduleId, definition.channel);
|
||||||
|
let snapshotPending = true;
|
||||||
|
let closed = false;
|
||||||
|
let pendingRead: Promise<ModuleRealtimePublication | undefined> | undefined;
|
||||||
|
let activeDelay: CancelableDelay | undefined;
|
||||||
|
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
activeDelay?.cancel();
|
||||||
|
activeDelay = undefined;
|
||||||
|
clearTimeout(expiryTimer);
|
||||||
|
expiryTimer = undefined;
|
||||||
|
subscription.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
expiryTimer = setTimeout(() => {
|
||||||
|
close();
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// A simultaneous browser cancellation or completed pull already closed the controller.
|
||||||
|
}
|
||||||
|
}, maxConnectionAgeMs);
|
||||||
|
},
|
||||||
|
cancel: () => close(),
|
||||||
|
async pull(controller) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (snapshotPending) {
|
||||||
|
snapshotPending = false;
|
||||||
|
controller.enqueue(encoder.encode(realtimeEvent("snapshot", subscription.snapshot)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only one Core read is ever outstanding. If a keepalive wins the race, the same read is
|
||||||
|
// retained for the next pull instead of allocating a second listener. Core itself holds only
|
||||||
|
// one newest pending publication, and this stream asks for work only when the browser-facing
|
||||||
|
// ReadableStream has demand, so a slow client cannot create an array at either layer.
|
||||||
|
pendingRead ??= subscription.read();
|
||||||
|
activeDelay = keepaliveDelay(keepaliveMs);
|
||||||
|
const result = await Promise.race([pendingRead, activeDelay.promise]);
|
||||||
|
activeDelay.cancel();
|
||||||
|
activeDelay = undefined;
|
||||||
|
|
||||||
|
if (result === "keepalive") {
|
||||||
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRead = undefined;
|
||||||
|
if (result === undefined) {
|
||||||
|
close();
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(encoder.encode(realtimeEvent("update", result)));
|
||||||
|
} catch {
|
||||||
|
close();
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// The browser may have canceled between the awaited read/timer and this close. Cancellation
|
||||||
|
// already performed the same idempotent subscription cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountChannel(
|
||||||
|
app: Hono,
|
||||||
|
definition: RegisteredModuleRealtimeChannel,
|
||||||
|
options: ModuleRealtimeRoutesOptions,
|
||||||
|
keepaliveMs: number,
|
||||||
|
maxConnectionAgeMs: number,
|
||||||
|
): void {
|
||||||
|
const requireChannelPermission = requirePermission(
|
||||||
|
options,
|
||||||
|
definition.permission as PermissionId,
|
||||||
|
);
|
||||||
|
app.get(
|
||||||
|
moduleRealtimeChannelUrl(definition.moduleId, definition.channel),
|
||||||
|
requireChannelPermission,
|
||||||
|
(c) => {
|
||||||
|
// Hono deliberately lets a registered GET handler answer HEAD. That is useful for finite
|
||||||
|
// resources, but unsafe for an infinite stream: the adapter discards the response body without
|
||||||
|
// necessarily canceling it, which could retain a Core subscriber forever. Refuse HEAD before
|
||||||
|
// allocating anything. Authentication/authorization has still run through the same middleware.
|
||||||
|
if (c.req.method !== "GET") {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
||||||
|
status: 405,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let stream: ReadableStream<Uint8Array>;
|
||||||
|
try {
|
||||||
|
stream = createRealtimeStream(
|
||||||
|
definition,
|
||||||
|
options.realtime,
|
||||||
|
keepaliveMs,
|
||||||
|
maxConnectionAgeMs,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return sanitizedRealtimeFailure(c, error);
|
||||||
|
}
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
|
Vary: "Origin, Cookie",
|
||||||
|
// Honored by nginx and harmless elsewhere. Other proxies must likewise disable buffering for
|
||||||
|
// this path; no proxy-specific behavior enters the module-facing contract.
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mounts one exact, immutable, permission-gated GET route per successfully started module channel.
|
||||||
|
* There is no dynamic module/channel lookup route and no request-controlled permission selection. */
|
||||||
|
export function mountModuleRealtimeRoutes(app: Hono, options: ModuleRealtimeRoutesOptions): void {
|
||||||
|
const keepaliveMs = options.keepaliveMs ?? MODULE_REALTIME_KEEPALIVE_MS;
|
||||||
|
const maxConnectionAgeMs = options.maxConnectionAgeMs ?? MODULE_REALTIME_MAX_CONNECTION_AGE_MS;
|
||||||
|
if (!Number.isSafeInteger(keepaliveMs) || keepaliveMs <= 0) {
|
||||||
|
throw new TypeError("The module realtime keepalive interval is invalid.");
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(maxConnectionAgeMs) || maxConnectionAgeMs <= 0) {
|
||||||
|
throw new TypeError("The module realtime maximum connection age is invalid.");
|
||||||
|
}
|
||||||
|
for (const definition of options.realtime.channels) {
|
||||||
|
if (!options.registry.has(definition.permission)) {
|
||||||
|
throw new ModuleRealtimeConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const definition of options.realtime.channels) {
|
||||||
|
mountChannel(app, definition, options, keepaliveMs, maxConnectionAgeMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||||
|
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||||
|
import type {
|
||||||
|
ModuleUiPublicationRegistry,
|
||||||
|
PublishableModuleContribution,
|
||||||
|
PublishableModuleUi,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
type AuthorizationMiddlewareOptions,
|
||||||
|
getAuthorizedPrincipal,
|
||||||
|
requirePermission,
|
||||||
|
} from "./authorization-middleware.js";
|
||||||
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
|
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||||
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
|
import {
|
||||||
|
MODULE_UI_ASSET_PREFIX,
|
||||||
|
moduleContributionArtifactUrl,
|
||||||
|
moduleUiArtifactUrl,
|
||||||
|
} from "./module-ui-routes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
|
||||||
|
* mount, and `/api/v1/module-ui/<module-id>/<version>` (ADR 0025) serves one artifact's bytes. One
|
||||||
|
* prefix, one resource, two representations.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_INVENTORY_PATH = MODULE_UI_ASSET_PREFIX;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly what a browser host needs to mount one module's UI, and deliberately nothing else.
|
||||||
|
*
|
||||||
|
* No filesystem path, package root, package name, server/schema/migrations entrypoint, manifest
|
||||||
|
* version, capability, dependency, or storage bucket appears here. Those are server-side facts a
|
||||||
|
* browser has no use for, and several of them are precisely what ADR 0025's publication route
|
||||||
|
* exists to keep off the wire.
|
||||||
|
*
|
||||||
|
* There is also no `name`, `icon`, `description`, `category`, `folder`, or `order`. Those are
|
||||||
|
* *navigation* concerns, and ADR 0021 established that Dashboard grouping is presentation
|
||||||
|
* configuration an administrator owns rather than something a module declares. A field added here
|
||||||
|
* now would quietly become module-owned navigation metadata before that design exists; the
|
||||||
|
* temporary selector renders the module id instead.
|
||||||
|
*/
|
||||||
|
export interface HostModuleUiDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
/** Where the browser fetches this module's compiled UI artifact. Produced by the server's own
|
||||||
|
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
||||||
|
* routing can change without touching a single line of host or module code. */
|
||||||
|
readonly ui: { readonly url: string };
|
||||||
|
/** Canonical page for this module's separately declared display artifact, or `null` when the
|
||||||
|
* module publishes no display. Server-produced so neither shell nor module spells platform
|
||||||
|
* routing or infers deployment topology. */
|
||||||
|
readonly display: { readonly url: string } | null;
|
||||||
|
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
|
||||||
|
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
|
||||||
|
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
|
||||||
|
* module nor the browser host should be the one spelling it. */
|
||||||
|
readonly api: { readonly baseUrl: string };
|
||||||
|
/** Server-produced root for this module's host-neutral browser realtime facade. The browser host,
|
||||||
|
* not module UI, resolves and appends a validated local channel. */
|
||||||
|
readonly realtime: { readonly baseUrl: string };
|
||||||
|
/**
|
||||||
|
* The subset of **this module's own declared permissions** that the current principal holds,
|
||||||
|
* evaluated server-side by Core's one authorization evaluator.
|
||||||
|
*
|
||||||
|
* Scoped to the module deliberately: a host has no business handing one module the platform's
|
||||||
|
* whole authorization state, and the module's manifest already declares exactly which permissions
|
||||||
|
* its UI can meaningfully ask about. Granted ids only — a permission the principal lacks is simply
|
||||||
|
* absent, so this never enumerates a module's full permission surface to someone who holds none of
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* **These are presentation hints, not authority** (ADR 0022). They exist so a UI can hide a
|
||||||
|
* control instead of offering one that would fail. Every module route remains enforced by
|
||||||
|
* `requirePermission` against the composed platform registry, and that enforcement neither reads
|
||||||
|
* nor trusts anything in this array.
|
||||||
|
*/
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
|
||||||
|
* the overwhelming majority of modules, which provide no extension point. */
|
||||||
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
|
||||||
|
*
|
||||||
|
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
|
||||||
|
* consuming module names a contribution and the host resolves it, so no module ever builds
|
||||||
|
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
|
||||||
|
*
|
||||||
|
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
|
||||||
|
* contributed renderer draws and what shape its input takes are the consuming module's contract,
|
||||||
|
* carried in the browser between two modules without passing through the platform.
|
||||||
|
*/
|
||||||
|
export interface HostModuleContributionDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
/** The capability it was contributed to — one this module declares. */
|
||||||
|
readonly capability: string;
|
||||||
|
readonly url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostModuleUiInventory {
|
||||||
|
readonly modules: readonly HostModuleUiDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
|
||||||
|
* inventory and the display descriptor so the two can never disagree about what a consuming module
|
||||||
|
* may mount.
|
||||||
|
*/
|
||||||
|
export function describeModuleContributions(
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
moduleId: string,
|
||||||
|
): readonly HostModuleContributionDescriptor[] {
|
||||||
|
return registry
|
||||||
|
.contributionsForModule(moduleId)
|
||||||
|
.map((contribution: PublishableModuleContribution) => ({
|
||||||
|
capability: contribution.capability,
|
||||||
|
id: contribution.contributionId,
|
||||||
|
url: moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
|
||||||
|
contribution.contributionId,
|
||||||
|
),
|
||||||
|
version: contribution.version,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
|
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||||
|
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||||
|
* no discovery, reads no manifest, and touches no filesystem; a module absent from this registry
|
||||||
|
* is absent from the inventory, by construction rather than by filtering. Named `moduleUi` rather
|
||||||
|
* than `registry` because `AuthorizationMiddlewareOptions.registry` is already the *permission*
|
||||||
|
* registry, and one route needs both. */
|
||||||
|
readonly moduleUi: ModuleUiPublicationRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates one module's declared permissions against the caller, returning only the granted ids.
|
||||||
|
*
|
||||||
|
* Uses Core's one evaluator, so baselines, the platform-owner bypass, grant expiry, and
|
||||||
|
* deny-by-default all behave exactly as they do on a real request — there is no second,
|
||||||
|
* browser-only interpretation of what a permission means. An infrastructure failure propagates as
|
||||||
|
* `AuthorizationEvaluationError` rather than being folded into "not granted", because a hint that
|
||||||
|
* silently read "denied" during a database outage would blank the whole UI and look like a
|
||||||
|
* permissions problem.
|
||||||
|
*/
|
||||||
|
/** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same
|
||||||
|
* presentation-only subset of a module's own declarations and use Core's one evaluator. */
|
||||||
|
export async function grantedModulePermissionIds(
|
||||||
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
declarations: readonly PermissionDeclaration[],
|
||||||
|
): Promise<readonly string[]> {
|
||||||
|
const decisions = await Promise.all(
|
||||||
|
declarations.map(async (declaration) => ({
|
||||||
|
allowed: (
|
||||||
|
await evaluator.authorize({
|
||||||
|
baseline: declaration.baseline,
|
||||||
|
permission: declaration.id,
|
||||||
|
principal,
|
||||||
|
})
|
||||||
|
).allowed,
|
||||||
|
id: String(declaration.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return decisions.filter((decision) => decision.allowed).map((decision) => decision.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeModule(
|
||||||
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
published: PublishableModuleUi,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
): Promise<HostModuleUiDescriptor> {
|
||||||
|
const display = registry.resolveDisplay(published.moduleId, published.version);
|
||||||
|
return {
|
||||||
|
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||||
|
contributions: describeModuleContributions(registry, published.moduleId),
|
||||||
|
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
|
||||||
|
id: published.moduleId,
|
||||||
|
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||||
|
ui: { url: moduleUiArtifactUrl(published) },
|
||||||
|
version: published.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One mountable module, described once: the host-neutral mount descriptor plus the module's own
|
||||||
|
* display name, kept beside it rather than inside it. */
|
||||||
|
export interface MountableModuleDescription {
|
||||||
|
readonly descriptor: HostModuleUiDescriptor;
|
||||||
|
/** `manifest.name`. Presentation *identity* the module owns — never its placement, which the
|
||||||
|
* Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the
|
||||||
|
* host-neutral contract a standalone host consumes stays free of presentation concerns. */
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one composition of the mountable-module set, shared by the generic inventory route and the
|
||||||
|
* Dashboard navigation route (`./dashboard-routes.js`).
|
||||||
|
*
|
||||||
|
* Both derive from the same publication registry and the same evaluator, so the two endpoints can
|
||||||
|
* never disagree about what exists, what its URLs are, or what the caller may do inside it. Sharing
|
||||||
|
* this function rather than duplicating the mapping is what makes that a structural property instead
|
||||||
|
* of a convention.
|
||||||
|
*/
|
||||||
|
export async function describeMountableModules(
|
||||||
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
|
principal: EkklesiaPrincipal,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
): Promise<readonly MountableModuleDescription[]> {
|
||||||
|
return Promise.all(
|
||||||
|
registry.list().map(async (published) => ({
|
||||||
|
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||||
|
name: published.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that
|
||||||
|
* could not be determined is never reported as "not granted". Carries no module id, permission id,
|
||||||
|
* or caught error. */
|
||||||
|
function sanitizedInventoryFailure(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser host's one bootstrap query: which module UIs may I mount, where do I fetch each one,
|
||||||
|
* where does each one's API live, and what may this viewer do inside it.
|
||||||
|
*
|
||||||
|
* ## It is not a module management API
|
||||||
|
*
|
||||||
|
* It lists mountable UIs and nothing else. There is no create, update, enable, disable, install, or
|
||||||
|
* configure; no module that declares no UI; and no field beyond what constructing a
|
||||||
|
* `ModuleUiHostContext` requires. Ekklesia has no enable/disable state to report —
|
||||||
|
* `ModuleLifecycleStage` is vocabulary with no runtime state machine behind it — and inventing one
|
||||||
|
* here would be inventing a lifecycle rather than reporting one.
|
||||||
|
*
|
||||||
|
* ## The module set comes from the publication registry, never a rescan
|
||||||
|
*
|
||||||
|
* `moduleUi.list()` is the exact accepted set ADR 0025 publishes artifacts from: modules that
|
||||||
|
* survived discovery and declare `entrypoints.ui`, captured after the module runtime started. This
|
||||||
|
* route never scans a directory, reads a manifest, or reconstructs a module from `package.json`, so
|
||||||
|
* the inventory and the artifact route can never disagree about what exists. Order is the
|
||||||
|
* registry's own discovery order, which is deterministic (topologically sorted, then by id).
|
||||||
|
*
|
||||||
|
* ## Authenticated, deliberately, unlike the artifact route
|
||||||
|
*
|
||||||
|
* ADR 0025 made the artifact bytes anonymous — fetching compiled UI code confers no authority — but
|
||||||
|
* was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong
|
||||||
|
* version collapse into one indistinguishable `404`, so a caller must already know both halves of an
|
||||||
|
* identity to learn anything. **This endpoint is that enumeration.** Serving it anonymously would
|
||||||
|
* hand over exactly what that route withholds, so it requires `core.modules.ui.read` (baseline
|
||||||
|
* `authenticated`): any signed-in user of the installation, and nobody else.
|
||||||
|
*
|
||||||
|
* Enforcement goes through the same `requirePermission` middleware every other protected route uses
|
||||||
|
* — no second browser-auth mechanism — which also resolves the principal exactly once and keeps a
|
||||||
|
* genuine denial (`403`) distinct from an infrastructure failure (`503`).
|
||||||
|
*
|
||||||
|
* The response is per-principal (its `permissions` depend on who is asking), so it is `no-store` and
|
||||||
|
* varies on `Origin` and `Cookie`, exactly like `/api/v1/principal`.
|
||||||
|
*/
|
||||||
|
export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
const requireInventoryPermission = requirePermission(
|
||||||
|
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
|
||||||
|
CORE_MODULES_UI_READ,
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(MODULE_UI_INVENTORY_PATH, requireInventoryPermission, async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
// Resolved and already authorized by the middleware above; never resolved a second time here.
|
||||||
|
// The `authenticated` baseline guarantees this is an authenticated principal.
|
||||||
|
const principal = getAuthorizedPrincipal(c);
|
||||||
|
try {
|
||||||
|
const described = await describeMountableModules(
|
||||||
|
options.evaluator,
|
||||||
|
principal,
|
||||||
|
options.moduleUi,
|
||||||
|
);
|
||||||
|
// Only the host-neutral descriptor reaches this response: the display name stays out of the
|
||||||
|
// portable contract and is carried by the Dashboard navigation view instead.
|
||||||
|
const modules = described.map((entry) => entry.descriptor);
|
||||||
|
return c.json({ modules } satisfies HostModuleUiInventory, 200);
|
||||||
|
} catch {
|
||||||
|
return sanitizedInventoryFailure(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform-owned URL space for published module UI artifacts.
|
||||||
|
*
|
||||||
|
* Deliberately **not** nested under `/api/v1/modules/<module-id>`, and that is a security decision
|
||||||
|
* rather than a stylistic one. A module may declare any route path matching Core's own grammar
|
||||||
|
* (`moduleRoutePathSchema`, `@ekklesia/contracts`), and `ui` is a perfectly legal lowercase static
|
||||||
|
* segment — so a module registering `GET /ui/:version` would be mounted at exactly
|
||||||
|
* `/api/v1/modules/<module-id>/ui/<version>`. Publishing there would put the platform and installed
|
||||||
|
* modules in the same URL namespace, with Hono's registration order silently deciding which one
|
||||||
|
* answers. A sibling prefix can never collide: every module route begins `/api/v1/modules/`, and
|
||||||
|
* nothing the platform serves here does.
|
||||||
|
*
|
||||||
|
* It stays under `/api/v1/*` so it inherits the application's one CORS policy (exact reflected
|
||||||
|
* trusted origins, never a wildcard) — which a cross-origin browser `import()` of this URL requires
|
||||||
|
* — instead of introducing a second, separately-configured public URL space.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_ASSET_PREFIX = "/api/v1/module-ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revalidate-always, with a strong `ETag` doing the actual work.
|
||||||
|
*
|
||||||
|
* The URL carries module id and exact installed version, which is *identity*, not *immutability*.
|
||||||
|
* Ekklesia has no installer, no content addressing, and no integrity verification, so nothing stops
|
||||||
|
* an operator from rebuilding version `1.2.3` with different bytes and restarting — the ordinary
|
||||||
|
* inner-loop of developing a module. `immutable` (or any non-zero `max-age`) would therefore be a
|
||||||
|
* promise the platform cannot keep, and a browser that cached a superseded artifact under a reused
|
||||||
|
* version would have no way to recover.
|
||||||
|
*
|
||||||
|
* `no-cache` is not `no-store`: the response is still cached, it just has to be revalidated. With
|
||||||
|
* the `ETag` below, an unchanged artifact costs one conditional request and a `304`, and a changed
|
||||||
|
* one is picked up immediately. A version bump changes the URL and never collides at all. When a
|
||||||
|
* real installer can guarantee that an installed version's bytes never change, this is the single
|
||||||
|
* constant that would move to immutable caching.
|
||||||
|
*/
|
||||||
|
export const MODULE_UI_CACHE_CONTROL = "no-cache";
|
||||||
|
|
||||||
|
/** Modern ESM is served as `text/javascript`, the one JavaScript type the WHATWG MIME sniffing
|
||||||
|
* standard designates as canonical; `application/javascript` is a legacy alias. A browser refuses
|
||||||
|
* to evaluate an ES module served as anything else, so this is a correctness requirement, not a
|
||||||
|
* courtesy. */
|
||||||
|
export const MODULE_UI_CONTENT_TYPE = "text/javascript; charset=utf-8";
|
||||||
|
|
||||||
|
export interface ModuleUiIdentity {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one canonical way to turn an installed module's identity into the browser URL its UI artifact
|
||||||
|
* is published at. Every producer of that URL — this package's own route registration, a future
|
||||||
|
* module-metadata endpoint, and a future Dashboard host — goes through here, so the URL shape is
|
||||||
|
* defined once instead of being re-spelled as a template string in each of them.
|
||||||
|
*
|
||||||
|
* The result is a **root-relative browser URL, never a filesystem path** — the distinction ADR 0024
|
||||||
|
* built the loader around. A host on the API's own origin passes it to `loadModuleUi` unchanged; a
|
||||||
|
* host on a different origin resolves it against the same API base URL it already needs for
|
||||||
|
* `ModuleUiApiClient` (`new URL(moduleUiArtifactUrl(identity), apiBaseUrl)`). It is deliberately not
|
||||||
|
* absolute: the API's public origin is deployment configuration, and baking one in here would break
|
||||||
|
* both split-origin deployments and reverse proxies.
|
||||||
|
*
|
||||||
|
* This function accepts **identity only**. There is no parameter for a filename, a relative path,
|
||||||
|
* or any other artifact selector, so no caller — however careless — can use it to reach a second
|
||||||
|
* file in a module package. Both components are percent-encoded, so even a value that never came
|
||||||
|
* from a validated manifest can only ever produce one extra path segment's worth of text, never a
|
||||||
|
* new segment, a traversal, a query, or a fragment. A value that needed encoding will simply fail
|
||||||
|
* to resolve against the publication registry.
|
||||||
|
*/
|
||||||
|
export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||||
|
return `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(identity.moduleId)}/${encodeURIComponent(
|
||||||
|
identity.version,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical browser URL for a module's independently declared display artifact.
|
||||||
|
*
|
||||||
|
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
|
||||||
|
* separate helper and an exact route means neither request input nor a generic role parameter can
|
||||||
|
* select a neighbouring artifact. A future standalone host may expose a different outer display
|
||||||
|
* page while consuming this same published module artifact internally.
|
||||||
|
*/
|
||||||
|
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||||
|
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
|
||||||
|
*
|
||||||
|
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
|
||||||
|
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
|
||||||
|
* module id and version are: a value that somehow did not come from a validated manifest can only
|
||||||
|
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
|
||||||
|
* fragment — and a value that needed encoding simply fails to resolve against the registry.
|
||||||
|
*
|
||||||
|
* Like every other publication helper, this takes **identity only**. There is no parameter for a
|
||||||
|
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
|
||||||
|
* module package.
|
||||||
|
*/
|
||||||
|
export function moduleContributionArtifactUrl(
|
||||||
|
identity: ModuleUiIdentity,
|
||||||
|
contributionId: string,
|
||||||
|
): string {
|
||||||
|
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||||
|
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||||
|
* the installed one.
|
||||||
|
*
|
||||||
|
* They are deliberately indistinguishable. The route requires no authentication (see
|
||||||
|
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
|
||||||
|
* which modules an installation runs and at which versions. Collapsing them means a caller must
|
||||||
|
* already know both the exact module id and the exact version to learn anything at all.
|
||||||
|
*/
|
||||||
|
function notPublished(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "MODULE_UI_NOT_PUBLISHED" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artifact was validated at discovery and is not retrievable now — deleted, replaced by a
|
||||||
|
* directory, or unreadable. That is a server-side condition rather than a bad request, and often a
|
||||||
|
* transient one (an operator mid-upgrade), so it reports like every other "could not be determined"
|
||||||
|
* result in this application: a sanitized 503 with a fixed code. Never carries the artifact path,
|
||||||
|
* the module directory, the `errno`, or the caught error.
|
||||||
|
*/
|
||||||
|
function artifactUnavailable(c: Context): Response {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9110 §13.1.2 evaluation of `If-None-Match`: `*` matches any existing representation, and
|
||||||
|
* every listed entity-tag is compared with the **weak** comparison function, so a client echoing
|
||||||
|
* `W/"…"` still matches the strong tag this route issued. Anything unparsable simply fails to
|
||||||
|
* match, which costs a full 200 and can never produce a wrongly-fresh 304.
|
||||||
|
*/
|
||||||
|
function isNotModified(headerValue: string | undefined, etag: string): boolean {
|
||||||
|
if (headerValue === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (headerValue.trim() === "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return headerValue.split(",").some((candidate) => {
|
||||||
|
const value = candidate.trim();
|
||||||
|
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublishedArtifact {
|
||||||
|
readonly artifactPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
|
||||||
|
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
|
||||||
|
// Discovery validated this exact path earlier; the file may still have changed or vanished
|
||||||
|
// since. The response is produced from whatever a single read of that one path returns right
|
||||||
|
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
|
||||||
|
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
|
||||||
|
// something else.
|
||||||
|
let artifact: Buffer;
|
||||||
|
try {
|
||||||
|
artifact = await readFile(published.artifactPath);
|
||||||
|
} catch {
|
||||||
|
return artifactUnavailable(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
|
||||||
|
// validator can never describe a different revision than the body does.
|
||||||
|
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
|
||||||
|
if (isNotModified(c.req.header("if-none-match"), etag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(artifact, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": MODULE_UI_CACHE_CONTROL,
|
||||||
|
"Content-Type": MODULE_UI_CONTENT_TYPE,
|
||||||
|
ETag: etag,
|
||||||
|
// The declared type is the only type: a browser must not be allowed to sniff a module
|
||||||
|
// artifact into some other interpretation, and `import()` requires the JavaScript type
|
||||||
|
// anyway.
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModuleUiRoutesOptions {
|
||||||
|
/** The platform's publication policy, built by Core from the discovered, started module
|
||||||
|
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
|
||||||
|
* every byte it serves from this registry and nothing else. */
|
||||||
|
readonly registry: ModuleUiPublicationRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes installed modules' validated `entrypoints.ui` artifacts at a platform-owned browser
|
||||||
|
* URL, closing the one gap between Core's server-side artifact path and the generic loader's
|
||||||
|
* browser-side URL (ADR 0024, ADR 0025).
|
||||||
|
*
|
||||||
|
* ## The request never touches the filesystem
|
||||||
|
*
|
||||||
|
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||||
|
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||||
|
* `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||||
|
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
||||||
|
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
||||||
|
* are all unreachable by construction rather than by filtering. An installed module directory never
|
||||||
|
* becomes a static file root, and only the single artifact its manifest declares is reachable.
|
||||||
|
*
|
||||||
|
* ## No authentication, deliberately
|
||||||
|
*
|
||||||
|
* Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an
|
||||||
|
* operator reviewed and installed; it carries no credential, and the host — not the artifact —
|
||||||
|
* supplies the principal and permissions at mount time. Every module API stays behind
|
||||||
|
* `requirePermission` against the composed platform registry, and nothing about fetching this file
|
||||||
|
* changes a single authorization decision.
|
||||||
|
*
|
||||||
|
* Requiring a session would also break the thing it is meant to protect: a browser's dynamic
|
||||||
|
* `import()` uses `credentials: "same-origin"`, so a cross-origin import (the split-origin
|
||||||
|
* deployments `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support) sends no cookie and would fail
|
||||||
|
* — a failure that tests green on a server and only shows up in a real browser, exactly the class of
|
||||||
|
* error ADR 0024 exists to prevent. Working around it would mean fetching the bytes and importing a
|
||||||
|
* `blob:` URL, which costs a host its `script-src` CSP.
|
||||||
|
*
|
||||||
|
* The honest cost is stated in ADR 0025: an anonymous caller who already knows a module id **and**
|
||||||
|
* its exact installed version can download that module's UI bundle. That is information disclosure,
|
||||||
|
* not authority, and it is the reason the failure responses above are indistinguishable.
|
||||||
|
*
|
||||||
|
* ## The artifact is never executed
|
||||||
|
*
|
||||||
|
* This route reads bytes and writes bytes. It does not `import()`, evaluate, transform, minify,
|
||||||
|
* re-bundle, or rewrite the artifact, and a module UI whose top level throws is served exactly like
|
||||||
|
* any other. Core's rule that discovery never executes `entrypoints.ui` (ADR 0021) is unchanged, and
|
||||||
|
* publication does not become a second, quieter way to run browser code on the server.
|
||||||
|
*/
|
||||||
|
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(
|
||||||
|
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
|
||||||
|
async (c) => {
|
||||||
|
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
|
||||||
|
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
|
||||||
|
// UI artifact and on no others.
|
||||||
|
const published = options.registry.resolveContribution(
|
||||||
|
c.req.param("moduleId"),
|
||||||
|
c.req.param("version"),
|
||||||
|
c.req.param("contributionId"),
|
||||||
|
);
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||||
|
const published = options.registry.resolveDisplay(
|
||||||
|
c.req.param("moduleId"),
|
||||||
|
c.req.param("version"),
|
||||||
|
);
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
|
||||||
|
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
+550
-49
@@ -1,25 +1,138 @@
|
|||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
type ClientAddressResolver,
|
||||||
|
createClientAddressResolver,
|
||||||
|
wrapFetchWithClientAddress,
|
||||||
|
} from "./client-address.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.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 { prepareStartupModuleInventory } from "./platform-permissions.js";
|
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||||
|
|
||||||
const connectionTimeoutMs = 10_000;
|
const connectionTimeoutMs = 10_000;
|
||||||
const operationTimeoutMs = 10_000;
|
const operationTimeoutMs = 10_000;
|
||||||
|
|
||||||
|
export interface NodeServerTimeouts {
|
||||||
|
readonly headersTimeoutMs: number;
|
||||||
|
readonly requestTimeoutMs: number;
|
||||||
|
readonly keepAliveTimeoutMs: number;
|
||||||
|
/**
|
||||||
|
* How often Node re-scans open connections for a header/request timeout violation
|
||||||
|
* (`http.Server`'s `connectionsCheckingInterval`). This is not merely a performance knob: Node
|
||||||
|
* enforces `headersTimeout`/`requestTimeout` only when this periodic check runs, and its own
|
||||||
|
* default (30s) is verified (`node --version` 24.15.0, direct `http.createServer` reproduction)
|
||||||
|
* to let a violating connection sit for up to a full additional interval past its actual
|
||||||
|
* timeout before Node ever closes it — so leaving this at Node's default would silently widen
|
||||||
|
* both timeouts above by up to 30s in the worst case. Kept small relative to `headersTimeoutMs`
|
||||||
|
* so a violation is detected promptly after it actually elapses.
|
||||||
|
*/
|
||||||
|
readonly connectionsCheckingIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finite Node HTTP server timeouts for this JSON-only API. Node's own defaults
|
||||||
|
* (`headersTimeout`: 60s, `requestTimeout`: 300s) are generous enough to let a slow-header or
|
||||||
|
* slow-body connection hold a socket for minutes with no reverse proxy in front; these values bound
|
||||||
|
* that exposure while remaining comfortable for the small JSON payloads this API ever accepts (see
|
||||||
|
* `./body-limits.js`). `headersTimeoutMs` must not exceed `requestTimeoutMs` — Node validates this
|
||||||
|
* relationship itself at server construction — so headers alone may take up to 20s, and the
|
||||||
|
* complete request (headers plus body) up to 30s, before Node itself responds `408` and closes the
|
||||||
|
* connection without ever invoking a handler (subject to `connectionsCheckingIntervalMs`'s own
|
||||||
|
* detection latency, above). `keepAliveTimeoutMs` matches Node's own default (5s) made explicit and
|
||||||
|
* typed rather than left implicit; it bounds only the idle window between requests on a reused
|
||||||
|
* connection, never an in-flight one.
|
||||||
|
*/
|
||||||
|
const PRODUCTION_NODE_SERVER_TIMEOUTS: NodeServerTimeouts = Object.freeze({
|
||||||
|
connectionsCheckingIntervalMs: 5_000,
|
||||||
|
headersTimeoutMs: 20_000,
|
||||||
|
keepAliveTimeoutMs: 5_000,
|
||||||
|
requestTimeoutMs: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two fixed operational lines a transport-level client failure may produce. Neither ever
|
||||||
|
* carries the remote address, request path, query, headers, cookies, body, forwarding chain, the
|
||||||
|
* caught error's own message, or the malformed bytes that caused it — an attacker controls all of
|
||||||
|
* those, and a parse failure's message in particular is derived directly from the rejected input.
|
||||||
|
*/
|
||||||
|
const NODE_SERVER_TIMEOUT_LOG_MESSAGE =
|
||||||
|
"[server] A connection was closed for exceeding a configured header/request timeout.";
|
||||||
|
const NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE =
|
||||||
|
"[server] A connection was closed after a malformed or oversized client request.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node's own documented default `clientError` responses, reproduced byte-for-byte (verified
|
||||||
|
* directly against the pinned Node 24 runtime by driving each case through a real socket with no
|
||||||
|
* listener attached). Registering *any* `clientError` listener suppresses that default entirely —
|
||||||
|
* `_http_server.js` only falls back when `server.emit("clientError", ...)` returns `false` — so a
|
||||||
|
* listener that merely observes would silently turn every 408/400/431 into a bare connection reset.
|
||||||
|
* Reproducing the exact bytes is what lets the platform log the event without changing what a
|
||||||
|
* caller actually receives.
|
||||||
|
*/
|
||||||
|
const NODE_CLIENT_ERROR_RESPONSES: Readonly<Record<string, string>> = Object.freeze({
|
||||||
|
ERR_HTTP_REQUEST_TIMEOUT: "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n",
|
||||||
|
HPE_HEADER_OVERFLOW: "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
|
||||||
|
});
|
||||||
|
const NODE_CLIENT_ERROR_DEFAULT_RESPONSE = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors Node's own `socketOnError` fallback exactly: write the code-appropriate response only
|
||||||
|
* when the socket is still writable *and* no response has already begun for the in-flight request,
|
||||||
|
* then destroy the socket. The `_httpMessage` guard is what prevents a second, corrupting status
|
||||||
|
* line when a handler already responded and only the (unread) request body subsequently timed out.
|
||||||
|
*/
|
||||||
|
function writeNodeClientErrorResponse(error: NodeJS.ErrnoException, socket: Socket): void {
|
||||||
|
const inFlightResponse = (socket as Socket & { _httpMessage?: { headersSent?: boolean } })
|
||||||
|
._httpMessage;
|
||||||
|
const alreadyResponding = inFlightResponse?.headersSent === true;
|
||||||
|
if (socket.writable && !alreadyResponding) {
|
||||||
|
const code = typeof error.code === "string" ? error.code : "";
|
||||||
|
socket.write(NODE_CLIENT_ERROR_RESPONSES[code] ?? NODE_CLIENT_ERROR_DEFAULT_RESPONSE);
|
||||||
|
}
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunningApi {
|
export interface RunningApi {
|
||||||
readonly app: Hono;
|
readonly app: Hono;
|
||||||
readonly server: ServerType;
|
readonly server: ServerType;
|
||||||
@@ -30,6 +143,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;
|
||||||
@@ -38,6 +160,28 @@ export interface StartApiOptions {
|
|||||||
* runtime startup. Never set by the executable entry point. See
|
* runtime startup. Never set by the executable entry point. See
|
||||||
* `prepareStartupModuleInventory`. */
|
* `prepareStartupModuleInventory`. */
|
||||||
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||||
|
/** Test-only escape hatch: narrows the Node header/request/keep-alive timeouts so a transport
|
||||||
|
* test can prove real timeout behavior deterministically without waiting out the production
|
||||||
|
* values. Never set by the executable entry point. */
|
||||||
|
readonly nodeServerTimeoutOverride?: Partial<NodeServerTimeouts>;
|
||||||
|
/** Test-only escape hatch: narrows the graceful HTTP drain deadline
|
||||||
|
* (`HTTP_DRAIN_TIMEOUT_MS`) so a forced close can be proven in milliseconds. Never set by the
|
||||||
|
* executable entry point. */
|
||||||
|
readonly drainTimeoutMsOverride?: number;
|
||||||
|
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
||||||
|
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
||||||
|
readonly moduleHookTimeoutMsOverride?: number;
|
||||||
|
/**
|
||||||
|
* Where deployment-supplied module secrets come from (ADR 0029) — the one *production* dependency
|
||||||
|
* in this options object rather than a test-only escape hatch.
|
||||||
|
*
|
||||||
|
* It is a dependency rather than a field on `ApiRuntimeConfig` because it is a lookup, not a
|
||||||
|
* validated value, and because the set of secrets depends on which modules are discovered — which
|
||||||
|
* is not known when configuration is validated. The executable entry point supplies an
|
||||||
|
* environment-backed source; omitting it supplies nothing, so a module declaring a required secret
|
||||||
|
* is reported `secret_missing` rather than silently starting without it.
|
||||||
|
*/
|
||||||
|
readonly moduleSecrets?: ModuleSecretSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||||
@@ -49,6 +193,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`). */
|
||||||
@@ -60,14 +229,146 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
/**
|
||||||
|
* The platform's fixed graceful-drain deadline. In-flight HTTP work has this long to finish after
|
||||||
|
* the listener stops accepting new connections; after it, connections are closed so process
|
||||||
|
* shutdown stays bounded. Not configurable in this slice.
|
||||||
|
*/
|
||||||
|
export const HTTP_DRAIN_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shutdown that did not complete cleanly. Carries only fixed booleans and the already-sanitized
|
||||||
|
* module-runtime error — never a request, socket, URL, credential, SQL, or stack detail — so an
|
||||||
|
* operator can see *which* stages failed without any of it becoming a disclosure channel.
|
||||||
|
*/
|
||||||
|
export class ApiShutdownError extends Error {
|
||||||
|
readonly drainTimedOut: boolean;
|
||||||
|
readonly moduleStopFailed: boolean;
|
||||||
|
readonly moduleStopError: unknown;
|
||||||
|
|
||||||
|
constructor(drainTimedOut: boolean, moduleStopError: unknown) {
|
||||||
|
super("The API did not shut down cleanly.");
|
||||||
|
this.name = "ApiShutdownError";
|
||||||
|
this.drainTimedOut = drainTimedOut;
|
||||||
|
this.moduleStopFailed = moduleStopError !== undefined;
|
||||||
|
this.moduleStopError = moduleStopError;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops accepting new connections, then waits up to `timeoutMs` for existing HTTP work to finish.
|
||||||
|
*
|
||||||
|
* At the deadline it closes idle and then still-active connections using Node's own supported
|
||||||
|
* public `Server` APIs (`closeIdleConnections`/`closeAllConnections`, both called only after
|
||||||
|
* `server.close()`), and stops waiting for the close callback. No private socket or server field is
|
||||||
|
* touched.
|
||||||
|
*
|
||||||
|
* Force-closing a connection ends the transport. It does **not** prove that arbitrary in-process
|
||||||
|
* handler JavaScript was canceled — a handler promise may still be running and may still settle.
|
||||||
|
* What is guaranteed is that shutdown no longer waits for it, and that the pool closes afterwards,
|
||||||
|
* so its late database work fails.
|
||||||
|
*/
|
||||||
|
/** `ServerType` is a union that also covers HTTP/2 servers, which do not expose these two methods.
|
||||||
|
* `serve()` here always creates a plain `http.Server`, which does; narrowing with a real capability
|
||||||
|
* check keeps that honest instead of asserting a cast. */
|
||||||
|
interface ConnectionClosingServer {
|
||||||
|
closeAllConnections(): void;
|
||||||
|
closeIdleConnections(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsConnectionClosing(
|
||||||
|
server: ServerType,
|
||||||
|
): server is ServerType & ConnectionClosingServer {
|
||||||
|
const candidate = server as Partial<ConnectionClosingServer>;
|
||||||
|
return (
|
||||||
|
typeof candidate.closeIdleConnections === "function" &&
|
||||||
|
typeof candidate.closeAllConnections === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drainHttpServer(
|
||||||
|
server: ServerType,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<"drained" | "forced"> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const closed = new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
closed.then(() => "drained" as const),
|
||||||
|
new Promise<"forced">((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve("forced"), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (outcome === "drained") {
|
||||||
|
return "drained";
|
||||||
|
}
|
||||||
|
if (supportsConnectionClosing(server)) {
|
||||||
|
server.closeIdleConnections();
|
||||||
|
server.closeAllConnections();
|
||||||
|
}
|
||||||
|
return "forced";
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
// The close callback may still fire later; nothing awaits it, and it carries no rejection.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the Node HTTP server. Two platform-owned transport concerns are applied at this exact
|
||||||
|
* boundary, below Hono entirely: `wrapFetchWithClientAddress` resolves and sanitizes the caller's
|
||||||
|
* address from the real socket peer before any route (including Better Auth's own rate limiter)
|
||||||
|
* ever sees the request (see `./client-address.js`), and `serverOptions` gives Node finite
|
||||||
|
* header/request/keep-alive timeouts so a slow-header or slow-body connection cannot hold a socket
|
||||||
|
* indefinitely. A connection Node itself closes for exceeding one of these timeouts never reaches
|
||||||
|
* `app.fetch` and never receives a second response — both are Node's own documented behavior for
|
||||||
|
* `headersTimeout`/`requestTimeout`, not something this function re-implements.
|
||||||
|
*/
|
||||||
|
function bindServer(
|
||||||
|
app: Hono,
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
clientAddressResolver: ClientAddressResolver,
|
||||||
|
timeouts: NodeServerTimeouts,
|
||||||
|
): Promise<ServerType> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const server = serve({ fetch: app.fetch, hostname: host, port }, () => {
|
const server = serve(
|
||||||
|
{
|
||||||
|
fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver),
|
||||||
|
hostname: host,
|
||||||
|
port,
|
||||||
|
serverOptions: {
|
||||||
|
connectionsCheckingInterval: timeouts.connectionsCheckingIntervalMs,
|
||||||
|
headersTimeout: timeouts.headersTimeoutMs,
|
||||||
|
keepAliveTimeout: timeouts.keepAliveTimeoutMs,
|
||||||
|
requestTimeout: timeouts.requestTimeoutMs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
resolve(server);
|
resolve(server);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// `headersTimeout`/`requestTimeout` do NOT surface as the server's general `'timeout'` event —
|
||||||
|
// that event only fires for `server.setTimeout()`'s idle-socket timer, which this server never
|
||||||
|
// arms. Verified directly against the pinned Node 24 runtime by driving a real slow-header and
|
||||||
|
// slow-body connection through a real socket: the only server-level event either produces is
|
||||||
|
// `'clientError'`, carrying `err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. Observing the failure
|
||||||
|
// therefore has to happen here, and — because registering a listener suppresses Node's own
|
||||||
|
// default response — this handler must reproduce that default itself.
|
||||||
|
server.on("clientError", (error: NodeJS.ErrnoException, socket: Socket) => {
|
||||||
|
console.error(
|
||||||
|
error.code === "ERR_HTTP_REQUEST_TIMEOUT"
|
||||||
|
? NODE_SERVER_TIMEOUT_LOG_MESSAGE
|
||||||
|
: NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE,
|
||||||
|
);
|
||||||
|
writeNodeClientErrorResponse(error, socket);
|
||||||
});
|
});
|
||||||
server.once("error", () => {
|
server.once("error", () => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
@@ -84,25 +385,42 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
|||||||
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
||||||
* platform permission registry from Core's own declarations plus every module's
|
* platform permission registry from Core's own declarations plus every module's
|
||||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||||
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
|
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
|
||||||
* Drizzle view over it — see `./database.js`).
|
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||||
* 3. Construct the Core identity runtime, authorization store, and evaluator.
|
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||||
* 4. Start the module runtime from that exact same discovered inventory
|
* then the Core identity runtime, authorization store, and evaluator.
|
||||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time.
|
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||||
* 5. Create the Hono application, mounting the started module runtime's published routes.
|
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||||
* 6. Bind the Node HTTP server.
|
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||||
|
* rediscovering modules a second time — passing it the same services object every module's
|
||||||
|
* `start`/`stop` hook receives.
|
||||||
|
* 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
|
||||||
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
||||||
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
||||||
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
||||||
* identity, authorization) exists before any executable module lifecycle code runs, and every
|
* module database facade, identity, authorization) exists before any executable module lifecycle
|
||||||
* module route is published only after the entire module runtime has registered and started
|
* code runs, and every module route is published only after the entire module runtime has
|
||||||
* successfully, so no request can ever reach a module before every module has started. Returns a
|
* registered and started successfully, so no request can ever reach a module before every module
|
||||||
* handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
|
||||||
* server, the module runtime, and the shared pool deterministically. Never applies migrations or
|
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
|
||||||
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
|
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
|
||||||
|
* there is nothing mounted to check.
|
||||||
|
*
|
||||||
|
* Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration
|
||||||
|
* cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather
|
||||||
|
* than starting every discovered module: the platform cannot know what an administrator decided, and
|
||||||
|
* starting a module they disabled is worse than not starting at all.
|
||||||
|
*
|
||||||
|
* Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can
|
||||||
|
* stop the server, the module runtime, and the shared pool deterministically. Never applies
|
||||||
|
* migrations, generates schema, or issues a module database query merely because modules were
|
||||||
|
* discovered — the database must already be migrated through `pnpm migrations:apply`.
|
||||||
*/
|
*/
|
||||||
export async function startApi(
|
export async function startApi(
|
||||||
config: ApiRuntimeConfig,
|
config: ApiRuntimeConfig,
|
||||||
@@ -120,34 +438,140 @@ export async function startApi(
|
|||||||
const databaseHandle = createApiDatabase(config.databaseUrl, {
|
const databaseHandle = createApiDatabase(config.databaseUrl, {
|
||||||
connectionTimeoutMs,
|
connectionTimeoutMs,
|
||||||
operationTimeoutMs,
|
operationTimeoutMs,
|
||||||
|
// The one validated transport policy reaches this runtime's single physical pool here; the
|
||||||
|
// identity, authorization, and module Drizzle views are all typed views over that same pool,
|
||||||
|
// so TLS never introduces a second connection.
|
||||||
|
transport: config.databaseTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Both host providers are constructed from validated configuration **before** anything that uses
|
||||||
|
// them, and neither touches the network while being built: an S3 client opens no connection and a
|
||||||
|
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
|
||||||
|
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
|
||||||
|
// `undefined` for both, which is a complete, valid deployment.
|
||||||
|
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
||||||
|
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
||||||
|
|
||||||
|
let 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 });
|
||||||
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;
|
||||||
|
try {
|
||||||
|
displayHostAssets =
|
||||||
|
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||||
|
} 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);
|
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||||
|
configurations: eligibility.configurations,
|
||||||
|
storages: moduleStorages,
|
||||||
|
...(options.moduleHookTimeoutMsOverride === undefined
|
||||||
|
? {}
|
||||||
|
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -161,20 +585,75 @@ export async function startApi(
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
moduleRoutes: moduleRuntime.routes,
|
moduleRoutes: moduleRuntime.routes,
|
||||||
runtime,
|
moduleRealtime: moduleRuntime.realtime,
|
||||||
|
moduleServices: services,
|
||||||
|
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||||
|
// inventory, and exposed only after startup succeeded — so the published set is the running
|
||||||
|
// set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no
|
||||||
|
// browser inventory, and appears in no resolved Dashboard navigation, by construction rather
|
||||||
|
// than by a filter somewhere downstream. Modules declaring no UI role are simply absent.
|
||||||
|
moduleUi,
|
||||||
|
...(displayHostAssets === undefined ? {} : { displayHostAssets }),
|
||||||
|
moduleLifecycle: {
|
||||||
|
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
|
||||||
|
// reports it actually started — read from the runtime rather than assumed equal to the
|
||||||
|
// eligible set.
|
||||||
|
snapshot: Object.freeze({
|
||||||
|
discoveredModules: inventory.modules,
|
||||||
|
eligibility: eligibility.entries,
|
||||||
|
startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)),
|
||||||
|
}),
|
||||||
|
store: moduleLifecycleStore,
|
||||||
|
},
|
||||||
|
moduleConfiguration: {
|
||||||
|
// The settings this process actually resolved from, so `restartRequired` is a comparison
|
||||||
|
// against what is running rather than a guess.
|
||||||
|
bootSettings: moduleConfigurationSettings,
|
||||||
|
discoveredModules: inventory.modules,
|
||||||
|
secrets: moduleSecrets,
|
||||||
|
snapshots: eligibility.configurations,
|
||||||
|
store: moduleConfigurationStore,
|
||||||
|
},
|
||||||
|
moduleStorage: {
|
||||||
|
providerConfigured: storageProvider !== undefined,
|
||||||
|
snapshots: moduleStorages,
|
||||||
|
},
|
||||||
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
|
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 }),
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
trustedOrigins: config.identity.trustedOrigins,
|
||||||
});
|
});
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientAddressResolver = createClientAddressResolver(config.clientAddress);
|
||||||
|
const timeouts: NodeServerTimeouts = {
|
||||||
|
...PRODUCTION_NODE_SERVER_TIMEOUTS,
|
||||||
|
...options.nodeServerTimeoutOverride,
|
||||||
|
};
|
||||||
|
|
||||||
let server: ServerType;
|
let server: ServerType;
|
||||||
try {
|
try {
|
||||||
server = await bindServer(app, config.host, config.port);
|
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;
|
||||||
}
|
}
|
||||||
@@ -184,28 +663,45 @@ 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: the HTTP listener is drained and
|
// observes it complete (or fail) exactly once. Realtime subscriptions are closed *before* HTTP
|
||||||
// closed first so no new or in-flight request can reach a module handler while the module
|
// drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every
|
||||||
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
|
// clean shutdown open until the forced-drain deadline. Order matters, and is now additionally
|
||||||
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
|
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
|
||||||
// shutdown result a caller should observe — but database cleanup still always runs first, so a
|
// no new or in-flight request can reach a module handler while the module runtime is stopping;
|
||||||
// stop failure never leaves the pool open.
|
// then the module runtime stops — its stop hooks may still use `services.database`, which is
|
||||||
|
// safe because the pool is not yet closed; only then does the shared pool close, so no module
|
||||||
|
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
|
||||||
|
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
|
||||||
|
// database cleanup still always runs first, so a stop failure never leaves the pool open.
|
||||||
|
const drainTimeoutMs = options.drainTimeoutMsOverride ?? HTTP_DRAIN_TIMEOUT_MS;
|
||||||
let shutdownPromise: Promise<void> | undefined;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = (): Promise<void> => {
|
const shutdown = (): Promise<void> => {
|
||||||
if (!shutdownPromise) {
|
if (!shutdownPromise) {
|
||||||
shutdownPromise = (async () => {
|
shutdownPromise = (async () => {
|
||||||
await new Promise<void>((resolve) => {
|
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
||||||
server.close(() => resolve());
|
// completes: an in-flight module command may still commit and publish its resulting state.
|
||||||
});
|
// `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run.
|
||||||
|
moduleRuntime.realtime.closeSubscriptions();
|
||||||
|
const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
|
||||||
let moduleStopError: unknown;
|
let moduleStopError: unknown;
|
||||||
try {
|
try {
|
||||||
await moduleRuntime.stop();
|
await moduleRuntime.stop();
|
||||||
} 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);
|
||||||
if (moduleStopError) {
|
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
|
||||||
throw moduleStopError;
|
// module-stop failure can never leave the pool open. Both failures are reported together,
|
||||||
|
// as fixed metadata: a forced drain is a genuine shutdown failure, not a success.
|
||||||
|
if (drainOutcome === "forced" || moduleStopError) {
|
||||||
|
throw new ApiShutdownError(
|
||||||
|
drainOutcome === "forced",
|
||||||
|
moduleStopError === undefined ? undefined : moduleStopError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -240,35 +736,40 @@ export function installShutdownSignalHandlers(
|
|||||||
process.on("SIGTERM", handleSignal);
|
process.on("SIGTERM", handleSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readConfigFromEnvironment(): {
|
/**
|
||||||
|
* Turns one deployment environment into this process's startup inputs.
|
||||||
|
*
|
||||||
|
* Every host infrastructure value comes from `loadHostConfiguration` — the same function
|
||||||
|
* `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
|
||||||
|
* it can never disagree about what this deployment is configured to use. Only the two values that
|
||||||
|
* describe *this listener* rather than the host's infrastructure are read here.
|
||||||
|
*/
|
||||||
|
export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
|
||||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
readonly 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);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
environment: host.environment,
|
||||||
input: {
|
input: {
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
clientAddress: host.clientAddress,
|
||||||
host: process.env.EKKLESIA_API_HOST,
|
databaseTransport: host.databaseTransport,
|
||||||
identity: {
|
databaseUrl: host.databaseUrl,
|
||||||
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
host: processEnvironment.EKKLESIA_API_HOST,
|
||||||
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
identity: host.identity,
|
||||||
trustedOrigins,
|
mail: host.mail,
|
||||||
},
|
port: processEnvironment.EKKLESIA_API_PORT,
|
||||||
port: process.env.EKKLESIA_API_PORT,
|
storage: host.storage,
|
||||||
},
|
},
|
||||||
|
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
+550
-4
@@ -1,4 +1,9 @@
|
|||||||
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationEvaluator,
|
type AuthorizationEvaluator,
|
||||||
type AuthorizationStore,
|
type AuthorizationStore,
|
||||||
@@ -7,8 +12,22 @@ import {
|
|||||||
} 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,
|
||||||
|
} 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 { 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";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -21,6 +40,35 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||||
|
* never real database behavior, so no PostgreSQL is required. */
|
||||||
|
function fakeModuleServices(): ModuleRuntimeServices {
|
||||||
|
const database = Object.freeze({
|
||||||
|
delete: vi.fn(),
|
||||||
|
execute: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
selectDistinct: vi.fn(),
|
||||||
|
selectDistinctOn: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
}) as unknown as ModuleDatabase;
|
||||||
|
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
|
||||||
@@ -40,7 +88,7 @@ function fakeAuthorization(overrides: {
|
|||||||
grantPlatformOwner: vi.fn(),
|
grantPlatformOwner: vi.fn(),
|
||||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
revokeGrant: vi.fn(),
|
revokeGrant: vi.fn(),
|
||||||
revokePlatformOwner: vi.fn(),
|
revokePlatformOwner: vi.fn(),
|
||||||
...overrides.store,
|
...overrides.store,
|
||||||
@@ -48,11 +96,32 @@ 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(),
|
||||||
|
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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -61,14 +130,232 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -86,6 +373,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -99,12 +392,45 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
expect(response.headers.get("vary")).toContain("Cookie");
|
expect(response.headers.get("vary")).toContain("Cookie");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never duplicates Origin in Vary even though the route sets it directly and Hono's own CORS middleware also appends it", async () => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
});
|
||||||
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({}),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal", {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
const vary = response.headers.get("vary") ?? "";
|
||||||
|
const tokens = vary.split(",").map((token) => token.trim().toLowerCase());
|
||||||
|
expect(tokens.filter((token) => token === "origin")).toHaveLength(1);
|
||||||
|
expect(tokens).toContain("cookie");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the authenticated principal shape containing only kind and userId", async () => {
|
it("returns the authenticated principal shape containing only kind and userId", async () => {
|
||||||
const runtime = fakeRuntime({
|
const runtime = fakeRuntime({
|
||||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
|
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
|
||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -126,6 +452,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -144,6 +476,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -158,9 +496,169 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
expect(JSON.stringify(body)).not.toContain("postgresql://");
|
expect(JSON.stringify(body)).not.toContain("postgresql://");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forces Cache-Control: no-store on every Better Auth response, including a successful one", async () => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })),
|
||||||
|
});
|
||||||
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({}),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ ok: true });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth request body limit", () => {
|
||||||
|
it("accepts a request exactly at the auth body limit", async () => {
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({}),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/sign-in/email", {
|
||||||
|
body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES),
|
||||||
|
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES) },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a request one byte over the auth body limit with a sanitized 413, never invoking Better Auth", async () => {
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({}),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/sign-in/email", {
|
||||||
|
body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES + 1),
|
||||||
|
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1) },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an oversized declared Content-Length from its header alone, without invoking Better Auth", async () => {
|
||||||
|
// A declared-oversized Content-Length is rejected synchronously from the header value itself
|
||||||
|
// (`hono/body-limit`'s pinned implementation never reads the stream for this case) — proven
|
||||||
|
// here by the outcome (413, Better Auth never invoked) rather than by instrumenting the
|
||||||
|
// stream, since this in-memory test harness's own `Request`/body plumbing may touch the
|
||||||
|
// stream for reasons unrelated to `bodyLimit`'s own logic.
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({}),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode("a"));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/sign-in/email", {
|
||||||
|
body: stream,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES * 10) },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an oversized streamed body with no declared Content-Length", async () => {
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({}),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
const chunk = new Uint8Array(1024).fill(97);
|
||||||
|
let enqueued = 0;
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
if (enqueued > AUTH_REQUEST_MAX_BODY_BYTES) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enqueued += chunk.length;
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/sign-in/email", {
|
||||||
|
body: stream,
|
||||||
|
duplex: "half",
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -175,6 +673,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -191,6 +695,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -206,6 +716,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -219,6 +735,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -239,6 +761,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -262,6 +790,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -288,6 +822,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
registry: emptyRegistry,
|
registry: emptyRegistry,
|
||||||
@@ -305,6 +845,12 @@ 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(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE,
|
||||||
|
createClientAddressResolver,
|
||||||
|
wrapFetchWithClientAddress,
|
||||||
|
} from "../src/client-address.js";
|
||||||
|
|
||||||
|
function fakeEnv(remoteAddress: string | undefined) {
|
||||||
|
return {
|
||||||
|
incoming: { socket: { remoteAddress } },
|
||||||
|
outgoing: {},
|
||||||
|
} as unknown as Parameters<typeof wrapFetchWithClientAddress>[0] extends (
|
||||||
|
request: Request,
|
||||||
|
env: infer Env,
|
||||||
|
) => unknown
|
||||||
|
? Env
|
||||||
|
: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("wrapFetchWithClientAddress", () => {
|
||||||
|
it("resolves the trusted address from the real socket peer and sets the internal header before calling the wrapped callback", async () => {
|
||||||
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||||
|
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||||
|
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||||
|
|
||||||
|
const request = new Request("https://example.test/api/auth/sign-in/email");
|
||||||
|
await wrapped(request, fakeEnv("203.0.113.9"));
|
||||||
|
|
||||||
|
expect(wrappedFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||||
|
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("203.0.113.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites — never appends to — a caller-supplied copy of the internal header", async () => {
|
||||||
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||||
|
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||||
|
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||||
|
|
||||||
|
const request = new Request("https://example.test/api/auth/sign-in/email", {
|
||||||
|
headers: { [TRUSTED_CLIENT_ADDRESS_HEADER]: "203.0.113.250" },
|
||||||
|
});
|
||||||
|
await wrapped(request, fakeEnv("192.0.2.1"));
|
||||||
|
|
||||||
|
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||||
|
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("192.0.2.1");
|
||||||
|
// A single value, never a comma-joined "caller-value, resolved-value" — set(), not append().
|
||||||
|
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).not.toContain(",");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a sanitized 503 and never invokes the wrapped callback when resolution fails", async () => {
|
||||||
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||||
|
const wrappedFetch = vi.fn().mockResolvedValue(new Response("should not be reached"));
|
||||||
|
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await wrapped(
|
||||||
|
new Request("https://example.test/api/auth/sign-in/email"),
|
||||||
|
fakeEnv(undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(wrappedFetch).not.toHaveBeenCalled();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never logs the peer address, headers, or request path on a resolution failure", async () => {
|
||||||
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||||
|
const wrapped = wrapFetchWithClientAddress(vi.fn(), resolver);
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wrapped(
|
||||||
|
new Request("https://example.test/api/auth/sign-in/email", {
|
||||||
|
headers: { cookie: "super-secret-session", "x-forwarded-for": "203.0.113.1" },
|
||||||
|
}),
|
||||||
|
fakeEnv("not-a-valid-ip"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
|
||||||
|
expect(emitted).not.toContain("super-secret-session");
|
||||||
|
expect(emitted).not.toContain("203.0.113.1");
|
||||||
|
expect(emitted).not.toContain("sign-in");
|
||||||
|
expect(emitted).not.toContain("not-a-valid-ip");
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the request method, body, and other headers unrelated to the internal address header", async () => {
|
||||||
|
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||||
|
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||||
|
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||||
|
|
||||||
|
const request = new Request("https://example.test/api/auth/sign-up/email", {
|
||||||
|
body: JSON.stringify({ email: "[email protected]" }),
|
||||||
|
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
await wrapped(request, fakeEnv("192.0.2.1"));
|
||||||
|
|
||||||
|
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||||
|
expect(forwardedRequest.method).toBe("POST");
|
||||||
|
expect(forwardedRequest.headers.get("origin")).toBe("https://dashboard.example.test");
|
||||||
|
expect(await forwardedRequest.text()).toBe(JSON.stringify({ email: "[email protected]" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,10 +21,17 @@ describe("API runtime configuration", () => {
|
|||||||
const config = createApiRuntimeConfig(validInput(), { environment: "production" });
|
const config = createApiRuntimeConfig(validInput(), { environment: "production" });
|
||||||
|
|
||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
|
clientAddress: { mode: "direct" },
|
||||||
|
// Production defaults to certificate-verified TLS with no configuration at all.
|
||||||
|
databaseTransport: { mode: "verify-full" },
|
||||||
databaseUrl: validInput().databaseUrl,
|
databaseUrl: validInput().databaseUrl,
|
||||||
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,
|
||||||
secret: validSecret,
|
secret: validSecret,
|
||||||
trustedOrigins: ["https://identity.example.test"],
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
},
|
},
|
||||||
@@ -131,6 +138,187 @@ describe("API runtime configuration", () => {
|
|||||||
expect(config.identity.baseUrl).toBe("http://localhost:3000");
|
expect(config.identity.baseUrl).toBe("http://localhost:3000");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults public signup to disabled and threads an explicit development enablement through to identity config", () => {
|
||||||
|
expect(createApiRuntimeConfig(validInput()).identity.publicSignupEnabled).toBe(false);
|
||||||
|
expect(
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
identity: {
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
publicSignupEnabled: true,
|
||||||
|
secret: validSecret,
|
||||||
|
trustedOrigins: ["http://localhost:3000"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
).identity.publicSignupEnabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a production runtime configuration that enables public signup, before any resource is constructed", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
identity: { ...validInput().identity, publicSignupEnabled: true },
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults client-address configuration to direct mode and threads explicit trusted-proxy configuration through", () => {
|
||||||
|
expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" });
|
||||||
|
|
||||||
|
const config = createApiRuntimeConfig({
|
||||||
|
...validInput(),
|
||||||
|
clientAddress: {
|
||||||
|
header: "x-forwarded-for",
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(config.clientAddress).toEqual({
|
||||||
|
header: "x-forwarded-for",
|
||||||
|
mode: "trusted-proxy",
|
||||||
|
trustedProxies: ["10.0.0.1"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PostgreSQL transport policy", () => {
|
||||||
|
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
|
||||||
|
expect(createApiRuntimeConfig(validInput()).databaseTransport).toEqual({
|
||||||
|
mode: "verify-full",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
identity: {
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
secret: validSecret,
|
||||||
|
trustedOrigins: ["http://localhost:3000"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
).databaseTransport,
|
||||||
|
).toEqual({ mode: "disable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a production configuration that disables TLS", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{ ...validInput(), databaseTransport: { mode: "disable" } },
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed or loosely spelled TLS mode in every environment, without coercion", () => {
|
||||||
|
for (const mode of [
|
||||||
|
"VERIFY-FULL",
|
||||||
|
" verify-full ",
|
||||||
|
"require",
|
||||||
|
"verify-ca",
|
||||||
|
"prefer",
|
||||||
|
"no-verify",
|
||||||
|
"true",
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
]) {
|
||||||
|
for (const environment of ["production", "development"] as const) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
databaseTransport: { mode },
|
||||||
|
...(environment === "development"
|
||||||
|
? {
|
||||||
|
identity: {
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
secret: validSecret,
|
||||||
|
trustedOrigins: ["http://localhost:3000"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
{ environment },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected mode ${JSON.stringify(mode)} to be rejected`).toMatchObject({
|
||||||
|
code: "DATABASE_TLS_MODE_INVALID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a database URL carrying its own TLS parameters, which the pinned driver would let override the policy", () => {
|
||||||
|
for (const parameter of [
|
||||||
|
"sslmode=disable",
|
||||||
|
"sslmode=require",
|
||||||
|
"sslmode=verify-full",
|
||||||
|
"ssl=true",
|
||||||
|
"sslrootcert=/tmp/ca.crt",
|
||||||
|
"sslcert=/tmp/c.crt",
|
||||||
|
"sslkey=/tmp/c.key",
|
||||||
|
"uselibpqcompat=true",
|
||||||
|
]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
databaseUrl: `postgresql://ekklesia:[email protected]:5432/ekklesia?${parameter}`,
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
|
||||||
|
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never leaks the database URL, credentials, host, or CA material through a transport error", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
...validInput(),
|
||||||
|
databaseTransport: { certificateAuthority: "-----BEGIN CERTIFICATE-----SECRET" },
|
||||||
|
databaseUrl:
|
||||||
|
"postgresql://admin:[email protected]:5432/ekklesia?sslmode=disable",
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
const publicText = `${String(captured)}\n${
|
||||||
|
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||||
|
}\n${JSON.stringify(captured)}`;
|
||||||
|
expect(publicText).not.toContain("hunter2");
|
||||||
|
expect(publicText).not.toContain("db.internal.example");
|
||||||
|
expect(publicText).not.toContain("SECRET");
|
||||||
|
expect(publicText).not.toContain("sslmode");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
|
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
|
||||||
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
|
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
|
||||||
let captured: unknown;
|
let captured: unknown;
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
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 {
|
||||||
|
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 ?? {}),
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
parseAuthorizationScope,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
parseGrantId,
|
parseGrantId,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
import { createGrantsRoutes } from "../src/grants-routes.js";
|
import { createGrantsRoutes } from "../src/grants-routes.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
@@ -49,7 +49,7 @@ function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationSt
|
|||||||
grantPlatformOwner: vi.fn(),
|
grantPlatformOwner: vi.fn(),
|
||||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
revokeGrant: vi.fn(),
|
revokeGrant: vi.fn(),
|
||||||
revokePlatformOwner: vi.fn(),
|
revokePlatformOwner: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -58,6 +58,7 @@ function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationSt
|
|||||||
|
|
||||||
function buildApp(options: {
|
function buildApp(options: {
|
||||||
readonly allowed?: boolean;
|
readonly allowed?: boolean;
|
||||||
|
readonly now?: () => Date;
|
||||||
readonly runtime?: EkklesiaIdentityRuntime;
|
readonly runtime?: EkklesiaIdentityRuntime;
|
||||||
readonly store?: AuthorizationStore;
|
readonly store?: AuthorizationStore;
|
||||||
}) {
|
}) {
|
||||||
@@ -71,6 +72,7 @@ function buildApp(options: {
|
|||||||
"/",
|
"/",
|
||||||
createGrantsRoutes({
|
createGrantsRoutes({
|
||||||
evaluator,
|
evaluator,
|
||||||
|
...(options.now === undefined ? {} : { now: options.now }),
|
||||||
registry: CORE_PERMISSION_REGISTRY,
|
registry: CORE_PERMISSION_REGISTRY,
|
||||||
runtime,
|
runtime,
|
||||||
store,
|
store,
|
||||||
@@ -102,15 +104,20 @@ describe("GET /api/v1/grants", () => {
|
|||||||
userId: subjectUserId,
|
userId: subjectUserId,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) });
|
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue({ grants }) });
|
||||||
const { app } = buildApp({ store });
|
const { app } = buildApp({ store });
|
||||||
|
|
||||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||||
const body = (await response.json()) as { grants: unknown[] };
|
const body = (await response.json()) as { grants: unknown[]; nextCursor: string | null };
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(body.grants).toHaveLength(1);
|
expect(body.grants).toHaveLength(1);
|
||||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" });
|
expect(body.nextCursor).toBeNull();
|
||||||
|
// The default page size is applied server-side; `sourceKind` filtering stays server-side too.
|
||||||
|
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||||
|
limit: 50,
|
||||||
|
sourceKind: "explicit",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a missing or malformed userId query parameter", async () => {
|
it("rejects a missing or malformed userId query parameter", async () => {
|
||||||
@@ -183,7 +190,6 @@ describe("POST /api/v1/grants", () => {
|
|||||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
id: grantId,
|
id: grantId,
|
||||||
permission: registeredPermission,
|
permission: registeredPermission,
|
||||||
scope: parseAuthorizationScope("calendar-event:123"),
|
|
||||||
sourceKind: "explicit" as const,
|
sourceKind: "explicit" as const,
|
||||||
userId: subjectUserId,
|
userId: subjectUserId,
|
||||||
};
|
};
|
||||||
@@ -191,11 +197,7 @@ describe("POST /api/v1/grants", () => {
|
|||||||
const { app } = buildApp({ store });
|
const { app } = buildApp({ store });
|
||||||
|
|
||||||
const response = await app.request("/api/v1/grants", {
|
const response = await app.request("/api/v1/grants", {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||||
permission: registeredPermission,
|
|
||||||
scope: "calendar-event:123",
|
|
||||||
subjectUserId,
|
|
||||||
}),
|
|
||||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@@ -207,11 +209,14 @@ describe("POST /api/v1/grants", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
actor: { type: "user", userId: manager.userId },
|
actor: { type: "user", userId: manager.userId },
|
||||||
permission: registeredPermission,
|
permission: registeredPermission,
|
||||||
scope: "calendar-event:123",
|
|
||||||
sourceKind: "explicit",
|
sourceKind: "explicit",
|
||||||
userId: subjectUserId,
|
userId: subjectUserId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// The generic HTTP surface creates unscoped explicit grants only.
|
||||||
|
expect(store.createGrant).toHaveBeenCalledWith(
|
||||||
|
expect.not.objectContaining({ scope: expect.anything() }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
|
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
|
||||||
@@ -378,3 +383,499 @@ describe("POST /api/v1/grants/:grantId/revoke", () => {
|
|||||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Core grant mutation request body limit", () => {
|
||||||
|
it("accepts a comfortably-under-limit grant-creation body and rejects one byte over the exact limit", async () => {
|
||||||
|
const store = fakeStore();
|
||||||
|
const { app } = buildApp({ store });
|
||||||
|
|
||||||
|
const overSizedBody = "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1);
|
||||||
|
const overResponse = await app.request("/api/v1/grants", {
|
||||||
|
body: overSizedBody,
|
||||||
|
headers: {
|
||||||
|
"content-length": String(overSizedBody.length),
|
||||||
|
"content-type": "application/json",
|
||||||
|
origin: trustedOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(overResponse.status).toBe(413);
|
||||||
|
expect(await overResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
expect(store.createGrant).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A well-formed, comfortably-under-limit payload still succeeds through the same route.
|
||||||
|
const underResponse = await app.request("/api/v1/grants", {
|
||||||
|
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(underResponse.status).not.toBe(413);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an oversized declared Content-Length on grant creation without invoking permission evaluation", async () => {
|
||||||
|
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
|
||||||
|
const evaluator: AuthorizationEvaluator = { authorize: evaluatorAuthorize };
|
||||||
|
const store = fakeStore();
|
||||||
|
const app = new Hono();
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
createGrantsRoutes({
|
||||||
|
evaluator,
|
||||||
|
registry: CORE_PERMISSION_REGISTRY,
|
||||||
|
runtime: fakeRuntime(),
|
||||||
|
store,
|
||||||
|
trustedOrigins: new Set([trustedOrigin]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/grants", {
|
||||||
|
body: "a",
|
||||||
|
headers: {
|
||||||
|
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES * 10),
|
||||||
|
"content-type": "application/json",
|
||||||
|
origin: trustedOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(evaluatorAuthorize).not.toHaveBeenCalled();
|
||||||
|
expect(store.createGrant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an oversized streamed grant-creation body with no declared Content-Length", async () => {
|
||||||
|
const store = fakeStore();
|
||||||
|
const { app } = buildApp({ store });
|
||||||
|
const chunk = new Uint8Array(1024).fill(97);
|
||||||
|
let enqueued = 0;
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enqueued += chunk.length;
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/grants", {
|
||||||
|
body: stream,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
expect(store.createGrant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
|
||||||
|
const store = fakeStore();
|
||||||
|
const { app } = buildApp({ store });
|
||||||
|
const chunk = new Uint8Array(1024).fill(97);
|
||||||
|
let pullCount = 0;
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
pullCount += 1;
|
||||||
|
if (pullCount > 3) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/grants", {
|
||||||
|
body: stream,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect(store.createGrant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also bounds the revoke mutation body", async () => {
|
||||||
|
const store = fakeStore();
|
||||||
|
const { app } = buildApp({ store });
|
||||||
|
|
||||||
|
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||||
|
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
|
||||||
|
headers: {
|
||||||
|
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
|
||||||
|
origin: trustedOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass 3 request-contract regressions. Every case here asserts both the sanitized public response
|
||||||
|
* *and* that the store was never reached, because the point of each fix is that a malformed or
|
||||||
|
* unsupported request is decided at the request boundary rather than turning into database work or
|
||||||
|
* an indistinguishable infrastructure failure.
|
||||||
|
*/
|
||||||
|
describe("explicit-grant request invariants", () => {
|
||||||
|
const validExpiry = "2099-01-01T00:00:00.000Z";
|
||||||
|
|
||||||
|
async function post(app: Hono, body: unknown) {
|
||||||
|
return await app.request("/api/v1/grants", {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects a scope field in any form, before any store mutation", async () => {
|
||||||
|
// Core keeps scoped-grant semantics for trusted internal callers, but no HTTP middleware
|
||||||
|
// supplies a scope to the evaluator, so a scope accepted here could never authorize anything.
|
||||||
|
for (const scope of ["calendar-event:123", "", " ", null, 42, {}, []]) {
|
||||||
|
const { app, store } = buildApp({});
|
||||||
|
const response = await post(app, { permission: registeredPermission, scope, subjectUserId });
|
||||||
|
|
||||||
|
expect(response.status, `scope ${JSON.stringify(scope)}`).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(store.createGrant).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts exactly one canonical expiration representation", async () => {
|
||||||
|
const createdGrant = {
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
expiresAt: new Date(validExpiry),
|
||||||
|
id: grantId,
|
||||||
|
permission: registeredPermission,
|
||||||
|
sourceKind: "explicit" as const,
|
||||||
|
userId: subjectUserId,
|
||||||
|
};
|
||||||
|
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
|
||||||
|
const { app } = buildApp({ store });
|
||||||
|
|
||||||
|
const response = await post(app, {
|
||||||
|
expiresAt: validExpiry,
|
||||||
|
permission: registeredPermission,
|
||||||
|
subjectUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(store.createGrant).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ expiresAt: new Date(validExpiry) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects normalized-impossible, locale, offset, imprecise, padded, and past expirations", async () => {
|
||||||
|
const now = new Date("2026-07-26T12:00:00.000Z");
|
||||||
|
for (const expiresAt of [
|
||||||
|
// Normalized impossible date: `new Date` silently rewrote this to March 3rd.
|
||||||
|
"2026-02-31T00:00:00.000Z",
|
||||||
|
"2026-13-01T00:00:00.000Z",
|
||||||
|
// Locale-shaped and date-only forms.
|
||||||
|
"March 5, 2027",
|
||||||
|
"2027-01-01",
|
||||||
|
// Missing milliseconds, excess fractional precision, offsets, lowercase separators.
|
||||||
|
"2027-01-01T00:00:00Z",
|
||||||
|
"2027-01-01T00:00:00.123456Z",
|
||||||
|
"2027-01-01T00:00:00.000+02:00",
|
||||||
|
"2027-01-01t00:00:00.000z",
|
||||||
|
"2027-01-01T00:00:00.000+00:00",
|
||||||
|
// Whitespace padding and trailing text.
|
||||||
|
" 2027-01-01T00:00:00.000Z",
|
||||||
|
"2027-01-01T00:00:00.000Z ",
|
||||||
|
"2027-01-01T00:00:00.000Z extra",
|
||||||
|
// Past and exactly-now are both dead on arrival.
|
||||||
|
"2020-01-01T00:00:00.000Z",
|
||||||
|
"2026-07-26T12:00:00.000Z",
|
||||||
|
// Non-strings.
|
||||||
|
42,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
]) {
|
||||||
|
const { app, store } = buildApp({ now: () => now });
|
||||||
|
const response = await post(app, {
|
||||||
|
expiresAt,
|
||||||
|
permission: registeredPermission,
|
||||||
|
subjectUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status, `expiresAt ${JSON.stringify(expiresAt)}`).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
expect(store.createGrant).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a sanitized 409 for an equivalent grant that is already effective", async () => {
|
||||||
|
const store = fakeStore({
|
||||||
|
createGrant: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS")),
|
||||||
|
});
|
||||||
|
const { app } = buildApp({ store });
|
||||||
|
|
||||||
|
const response = await post(app, { permission: registeredPermission, subjectUserId });
|
||||||
|
const raw = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
// The body is the stable code and nothing else — no existing grant id, actor, scope, source
|
||||||
|
// reference, expiry, SQL, constraint name, or driver text.
|
||||||
|
expect(Object.keys(JSON.parse(raw))).toEqual(["code"]);
|
||||||
|
for (const leaked of [
|
||||||
|
grantId,
|
||||||
|
manager.userId,
|
||||||
|
subjectUserId,
|
||||||
|
"calendar-event",
|
||||||
|
"constraint",
|
||||||
|
"select ",
|
||||||
|
"insert ",
|
||||||
|
"ekklesia_core_grant",
|
||||||
|
"Error",
|
||||||
|
"at ",
|
||||||
|
]) {
|
||||||
|
expect(raw, `must not leak ${leaked}`).not.toContain(leaked);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-canonical grant id on revoke without ever calling the store", async () => {
|
||||||
|
for (const raw of [
|
||||||
|
"not-a-uuid",
|
||||||
|
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
|
||||||
|
"%200b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
|
||||||
|
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
|
||||||
|
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f700",
|
||||||
|
"0b36b1b69a294c4e9e2d4c0e4c5e6f70",
|
||||||
|
]) {
|
||||||
|
const { app, store } = buildApp({});
|
||||||
|
const response = await app.request(`/api/v1/grants/${raw}/revoke`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status, `grantId ${raw}`).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still returns the sanitized 404 for a canonical but unknown grant id", async () => {
|
||||||
|
const store = fakeStore({
|
||||||
|
revokeGrant: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
|
||||||
|
});
|
||||||
|
const { app } = buildApp({ store });
|
||||||
|
|
||||||
|
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||||
|
expect(store.revokeGrant).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("explicit-grant listing pagination", () => {
|
||||||
|
function grantAt(index: number, iso: string) {
|
||||||
|
return {
|
||||||
|
createdAt: new Date(iso),
|
||||||
|
id: parseGrantId(`00000000-0000-4000-8000-${String(index).padStart(12, "0")}`),
|
||||||
|
permission: registeredPermission,
|
||||||
|
sourceKind: "explicit" as const,
|
||||||
|
userId: subjectUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("applies the default limit of 50 and accepts the 1 and 100 boundaries", async () => {
|
||||||
|
for (const [query, expected] of [
|
||||||
|
["", 50],
|
||||||
|
["&limit=1", 1],
|
||||||
|
["&limit=50", 50],
|
||||||
|
["&limit=100", 100],
|
||||||
|
] as const) {
|
||||||
|
const { app, store } = buildApp({});
|
||||||
|
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}${query}`);
|
||||||
|
|
||||||
|
expect(response.status, query).toBe(200);
|
||||||
|
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||||
|
limit: expected,
|
||||||
|
sourceKind: "explicit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects every non-canonical or out-of-range limit before any store work", async () => {
|
||||||
|
for (const limit of [
|
||||||
|
"0",
|
||||||
|
"101",
|
||||||
|
"-1",
|
||||||
|
"+5",
|
||||||
|
"1.0",
|
||||||
|
"1.5",
|
||||||
|
"01",
|
||||||
|
"005",
|
||||||
|
" 5",
|
||||||
|
"5 ",
|
||||||
|
"1e2",
|
||||||
|
"",
|
||||||
|
"abc",
|
||||||
|
"0x10",
|
||||||
|
"Infinity",
|
||||||
|
"NaN",
|
||||||
|
]) {
|
||||||
|
const { app, store } = buildApp({});
|
||||||
|
const response = await app.request(
|
||||||
|
`/api/v1/grants?userId=${subjectUserId}&limit=${encodeURIComponent(limit)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status, `limit ${JSON.stringify(limit)}`).toBe(400);
|
||||||
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown and duplicated query keys", async () => {
|
||||||
|
for (const query of [
|
||||||
|
`userId=${subjectUserId}&sort=asc`,
|
||||||
|
`userId=${subjectUserId}&offset=10`,
|
||||||
|
`userId=${subjectUserId}&sourceKind=relationship`,
|
||||||
|
`userId=${subjectUserId}&userId=${subjectUserId}`,
|
||||||
|
`userId=${subjectUserId}&limit=5&limit=6`,
|
||||||
|
`userId=${subjectUserId}&cursor=a&cursor=b`,
|
||||||
|
`userId=${subjectUserId}&cursor=`,
|
||||||
|
]) {
|
||||||
|
const { app, store } = buildApp({});
|
||||||
|
const response = await app.request(`/api/v1/grants?${query}`);
|
||||||
|
|
||||||
|
expect(response.status, query).toBe(400);
|
||||||
|
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a cursor only when another page exists, and traverses without duplicates or omissions", async () => {
|
||||||
|
// Three rows sharing one `created_at`, which is exactly the case a timestamp-only keyset
|
||||||
|
// boundary would skip or repeat.
|
||||||
|
const sameInstant = "2026-01-01T00:00:00.000Z";
|
||||||
|
const all = [grantAt(3, sameInstant), grantAt(2, sameInstant), grantAt(1, sameInstant)];
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let pages = 0;
|
||||||
|
do {
|
||||||
|
const store = fakeStore({
|
||||||
|
listGrantsForUser: vi.fn().mockImplementation((_userId, listOptions) => {
|
||||||
|
const startIndex =
|
||||||
|
listOptions.after === undefined
|
||||||
|
? 0
|
||||||
|
: all.findIndex((grant) => grant.id === listOptions.after.id) + 1;
|
||||||
|
const slice = all.slice(startIndex, startIndex + listOptions.limit);
|
||||||
|
const more = startIndex + listOptions.limit < all.length;
|
||||||
|
const last = slice.at(-1);
|
||||||
|
return Promise.resolve({
|
||||||
|
grants: slice,
|
||||||
|
...(more && last ? { nextBoundary: { createdAt: last.createdAt, id: last.id } } : {}),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { app } = buildApp({ store });
|
||||||
|
const url: string = `/api/v1/grants?userId=${subjectUserId}&limit=1${
|
||||||
|
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
|
||||||
|
}`;
|
||||||
|
const response = await app.request(url);
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
grants: { id: string }[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(body.grants).toHaveLength(1);
|
||||||
|
seen.push(body.grants[0]?.id as string);
|
||||||
|
cursor = body.nextCursor;
|
||||||
|
pages += 1;
|
||||||
|
} while (cursor !== null && pages < 10);
|
||||||
|
|
||||||
|
expect(pages).toBe(3);
|
||||||
|
expect(seen).toEqual(all.map((grant) => grant.id));
|
||||||
|
expect(new Set(seen).size).toBe(3);
|
||||||
|
// The final page ends the traversal explicitly.
|
||||||
|
expect(cursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a cursor issued for a different subject, and every malformed encoding", async () => {
|
||||||
|
const boundary = { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId };
|
||||||
|
const store = fakeStore({
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({
|
||||||
|
grants: [grantAt(1, boundary.createdAt.toISOString())],
|
||||||
|
nextBoundary: boundary,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { app: issuingApp } = buildApp({ store });
|
||||||
|
const issued = (await (
|
||||||
|
await issuingApp.request(`/api/v1/grants?userId=${subjectUserId}&limit=1`)
|
||||||
|
).json()) as { nextCursor: string };
|
||||||
|
expect(issued.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u);
|
||||||
|
|
||||||
|
const encode = (payload: unknown) =>
|
||||||
|
Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||||
|
const canonical = {
|
||||||
|
g: grantId,
|
||||||
|
t: "2026-01-01T00:00:00.000Z",
|
||||||
|
u: subjectUserId,
|
||||||
|
v: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [label, cursor] of [
|
||||||
|
// A cursor minted for a different subject must not be replayable against this one.
|
||||||
|
["other subject", encode({ ...canonical, u: "someone_else_1" })],
|
||||||
|
["wrong version", encode({ ...canonical, v: 2 })],
|
||||||
|
["extra field", encode({ ...canonical, extra: "x" })],
|
||||||
|
["missing field", encode({ g: canonical.g, t: canonical.t, u: canonical.u })],
|
||||||
|
["invalid timestamp", encode({ ...canonical, t: "2026-01-01" })],
|
||||||
|
["non-canonical timestamp", encode({ ...canonical, t: "2026-01-01T00:00:00Z" })],
|
||||||
|
["invalid grant id", encode({ ...canonical, g: "not-a-uuid" })],
|
||||||
|
["array payload", encode([canonical])],
|
||||||
|
["not json", Buffer.from("nonsense", "utf8").toString("base64url")],
|
||||||
|
["padded base64", `${Buffer.from(JSON.stringify(canonical), "utf8").toString("base64")}`],
|
||||||
|
["malformed base64url", "!!!not-base64!!!"],
|
||||||
|
["trailing data", `${encode(canonical)}AA`],
|
||||||
|
] as const) {
|
||||||
|
const { app, store: pagedStore } = buildApp({});
|
||||||
|
const response = await app.request(
|
||||||
|
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(cursor)}`,
|
||||||
|
);
|
||||||
|
const raw = await response.text();
|
||||||
|
|
||||||
|
expect(response.status, label).toBe(400);
|
||||||
|
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
// A rejected cursor reveals nothing it decoded, and does zero store/database work.
|
||||||
|
expect(raw).not.toContain(grantId);
|
||||||
|
expect(raw).not.toContain("someone_else_1");
|
||||||
|
expect(pagedStore.listGrantsForUser).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cursor the API itself issued is accepted.
|
||||||
|
const { app: acceptingApp, store: acceptingStore } = buildApp({});
|
||||||
|
const accepted = await acceptingApp.request(
|
||||||
|
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(issued.nextCursor)}`,
|
||||||
|
);
|
||||||
|
expect(accepted.status).toBe(200);
|
||||||
|
expect(acceptingStore.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||||
|
after: boundary,
|
||||||
|
limit: 50,
|
||||||
|
sourceKind: "explicit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,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,16 @@ 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());
|
||||||
|
|
||||||
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 }));
|
||||||
|
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 +35,7 @@ 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 { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||||
|
|
||||||
function validConfig() {
|
function validConfig() {
|
||||||
@@ -48,6 +54,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 +88,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: 3,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -111,13 +132,31 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
|||||||
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same globalThis-symbol technique as `createEventRecorder`, but captures an actual object
|
||||||
|
* reference (such as `context.services.database`) rather than a string label, so a test can prove
|
||||||
|
* two values pushed from different, independently dynamically-imported invocations (a module's own
|
||||||
|
* `start` hook and one of its HTTP handlers) are the exact same object. */
|
||||||
|
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||||
|
const key = `ekklesia.api.lifecycle.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||||
|
const symbol = Symbol.for(key);
|
||||||
|
const values: unknown[] = [];
|
||||||
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
||||||
|
eventSymbols.push(symbol);
|
||||||
|
return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkingFixtureModuleOptions {
|
interface WorkingFixtureModuleOptions {
|
||||||
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||||
readonly withRoute?: boolean;
|
readonly withRoute?: boolean;
|
||||||
readonly registerThrows?: boolean;
|
readonly registerThrows?: boolean;
|
||||||
readonly startThrows?: boolean;
|
readonly startThrows?: boolean;
|
||||||
readonly stopThrows?: boolean;
|
readonly stopThrows?: boolean;
|
||||||
|
/** Test-only: the stop hook records its marker and then never settles, so an API-level deadline
|
||||||
|
* can be exercised deterministically without any sleep. */
|
||||||
|
readonly stopNeverSettles?: boolean;
|
||||||
readonly dependencies?: Record<string, unknown>;
|
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
|
||||||
@@ -131,17 +170,49 @@ 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",
|
||||||
path: "/ping",
|
path: "/ping",
|
||||||
permission: ${JSON.stringify(permissionId)},
|
permission: ${JSON.stringify(permissionId)},
|
||||||
|
maxBodyBytes: 0,
|
||||||
handler: async () => new Response("pong", { status: 200 }),
|
handler: async () => new Response("pong", { status: 200 }),
|
||||||
});`
|
});`
|
||||||
: "";
|
: "";
|
||||||
@@ -160,6 +231,7 @@ async function createWorkingFixtureModule(
|
|||||||
stop() {
|
stop() {
|
||||||
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
||||||
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
|
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
|
||||||
|
${options.stopNeverSettles ? "return new Promise(() => {});" : ""}
|
||||||
},
|
},
|
||||||
};`;
|
};`;
|
||||||
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
|
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
|
||||||
@@ -182,12 +254,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: 3,
|
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,
|
||||||
@@ -199,8 +272,11 @@ function fakeDiscoveredModule(
|
|||||||
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
|
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
|
||||||
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
|
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
|
||||||
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
|
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
|
||||||
|
type ClientErrorHandler = (error: NodeJS.ErrnoException, socket: import("node:net").Socket) => void;
|
||||||
|
|
||||||
function fakeServer() {
|
function fakeServer() {
|
||||||
let errorHandler: ((error: Error) => void) | undefined;
|
let errorHandler: ((error: Error) => void) | undefined;
|
||||||
|
let clientErrorHandler: ClientErrorHandler | undefined;
|
||||||
const server = {
|
const server = {
|
||||||
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
|
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
|
||||||
close: vi.fn((callback?: () => void) => {
|
close: vi.fn((callback?: () => void) => {
|
||||||
@@ -208,6 +284,12 @@ function fakeServer() {
|
|||||||
return server;
|
return server;
|
||||||
}),
|
}),
|
||||||
listening: true,
|
listening: true,
|
||||||
|
on: vi.fn((event: string, handler: ClientErrorHandler) => {
|
||||||
|
if (event === "clientError") {
|
||||||
|
clientErrorHandler = handler;
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
}),
|
||||||
once: vi.fn((event: string, handler: (error: Error) => void) => {
|
once: vi.fn((event: string, handler: (error: Error) => void) => {
|
||||||
if (event === "error") {
|
if (event === "error") {
|
||||||
errorHandler = handler;
|
errorHandler = handler;
|
||||||
@@ -216,6 +298,9 @@ function fakeServer() {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
/** The `clientError` handler `bindServer` registered — the real event path Node uses for a
|
||||||
|
* header/request timeout, a parse failure, and a header overflow alike. */
|
||||||
|
clientErrorHandler: () => clientErrorHandler,
|
||||||
server,
|
server,
|
||||||
triggerError: (error: Error) => errorHandler?.(error),
|
triggerError: (error: Error) => errorHandler?.(error),
|
||||||
};
|
};
|
||||||
@@ -226,12 +311,12 @@ function fakeServer() {
|
|||||||
* resolving synchronously would reference `bindServer`'s own `server` const before it is
|
* resolving synchronously would reference `bindServer`'s own `server` const before it is
|
||||||
* initialized. */
|
* initialized. */
|
||||||
function mockSuccessfulServe() {
|
function mockSuccessfulServe() {
|
||||||
const { server, triggerError } = fakeServer();
|
const { clientErrorHandler, server, triggerError } = fakeServer();
|
||||||
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
|
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
|
||||||
queueMicrotask(() => listeningListener?.());
|
queueMicrotask(() => listeningListener?.());
|
||||||
return server;
|
return server;
|
||||||
});
|
});
|
||||||
return { server, triggerError };
|
return { clientErrorHandler, server, triggerError };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockFailingServe(error: Error) {
|
function mockFailingServe(error: Error) {
|
||||||
@@ -329,7 +414,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();
|
||||||
@@ -345,7 +430,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();
|
||||||
@@ -365,7 +450,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
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();
|
||||||
@@ -381,7 +466,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");
|
||||||
@@ -396,10 +481,101 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
expect(serveMock).not.toHaveBeenCalled();
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||||
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
// The rejection happens in `createApiRuntimeConfig` itself — the same boundary the executable
|
||||||
|
// entry point calls before `startApi` — so `startApi` is never even reachable with such a
|
||||||
|
// configuration, and no runtime resource is ever constructed to leak.
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: "https://identity.example.test",
|
||||||
|
publicSignupEnabled: true,
|
||||||
|
secret: "correct-horse-battery-staple-0123456789",
|
||||||
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
expect(poolEnd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||||
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
// Both an explicit production plaintext selection and a URL that would silently downgrade the
|
||||||
|
// policy are rejected in `createApiRuntimeConfig` — the same boundary the executable entry
|
||||||
|
// point calls before `startApi` — so no runtime resource is ever constructed to leak.
|
||||||
|
for (const [label, input] of [
|
||||||
|
[
|
||||||
|
"explicit production plaintext",
|
||||||
|
{
|
||||||
|
databaseTransport: { mode: "disable" },
|
||||||
|
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"connection-string TLS downgrade",
|
||||||
|
{
|
||||||
|
databaseUrl:
|
||||||
|
"postgresql://ekklesia:[email protected]:5432/ekklesia?sslmode=disable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: "https://identity.example.test",
|
||||||
|
secret: "correct-horse-battery-staple-0123456789",
|
||||||
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
...input,
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured, `expected ${label} to be rejected`).toBeInstanceOf(Error);
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
expect(poolEnd).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
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"));
|
||||||
@@ -452,6 +628,199 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Ekklesia API Node server timeouts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
poolConstructor.mockReset();
|
||||||
|
betterAuthMock.mockReset();
|
||||||
|
drizzleAdapterMock.mockClear();
|
||||||
|
serveMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
|
||||||
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig());
|
||||||
|
|
||||||
|
expect(serveMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [options] = serveMock.mock.calls[0] as [
|
||||||
|
{
|
||||||
|
serverOptions?: {
|
||||||
|
connectionsCheckingInterval?: number;
|
||||||
|
headersTimeout?: number;
|
||||||
|
keepAliveTimeout?: number;
|
||||||
|
requestTimeout?: number;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(options.serverOptions?.headersTimeout).toBeGreaterThan(0);
|
||||||
|
expect(options.serverOptions?.requestTimeout).toBeGreaterThan(0);
|
||||||
|
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
|
||||||
|
// Node only enforces headersTimeout/requestTimeout when this periodic check runs (verified
|
||||||
|
// directly against the pinned Node runtime — its own default, 30s, would otherwise let a
|
||||||
|
// violating connection sit for up to an additional 30s past its actual timeout).
|
||||||
|
expect(options.serverOptions?.connectionsCheckingInterval).toBeGreaterThan(0);
|
||||||
|
expect(options.serverOptions?.connectionsCheckingInterval).toBeLessThan(30_000);
|
||||||
|
// Node requires headersTimeout not to exceed requestTimeout.
|
||||||
|
expect(options.serverOptions?.headersTimeout).toBeLessThanOrEqual(
|
||||||
|
options.serverOptions?.requestTimeout ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
|
||||||
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), {
|
||||||
|
nodeServerTimeoutOverride: { headersTimeoutMs: 50, requestTimeoutMs: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [options] = serveMock.mock.calls[0] as [
|
||||||
|
{
|
||||||
|
serverOptions?: {
|
||||||
|
headersTimeout?: number;
|
||||||
|
keepAliveTimeout?: number;
|
||||||
|
requestTimeout?: number;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(options.serverOptions?.headersTimeout).toBe(50);
|
||||||
|
expect(options.serverOptions?.requestTimeout).toBe(100);
|
||||||
|
// The un-overridden keep-alive timeout keeps its production default.
|
||||||
|
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Whether Node actually *emits* anything for a real header/request timeout — and that exactly one
|
||||||
|
// 408 is written, the socket closes, and no handler runs — is proven only at the real transport
|
||||||
|
// boundary, by the raw-socket scenarios in `apps/api/tests/api-http.integration.ts`. Node's
|
||||||
|
// general `'timeout'` event is NOT that path (it belongs to `server.setTimeout()`, which this
|
||||||
|
// server never arms); `'clientError'` is. The tests below therefore deliberately do not claim to
|
||||||
|
// prove the event path at all — they cover only the response-reproduction logic that Node's own
|
||||||
|
// default would otherwise have performed, including the duplicate-response guard, which is
|
||||||
|
// impractical to drive from a real socket.
|
||||||
|
function fakeErrorSocket(overrides: { headersSent?: boolean; writable?: boolean } = {}) {
|
||||||
|
const socket = {
|
||||||
|
_httpMessage:
|
||||||
|
overrides.headersSent === undefined ? undefined : { headersSent: overrides.headersSent },
|
||||||
|
destroy: vi.fn(),
|
||||||
|
destroyed: false,
|
||||||
|
writable: overrides.writable ?? true,
|
||||||
|
write: vi.fn(),
|
||||||
|
};
|
||||||
|
return socket as unknown as import("node:net").Socket & {
|
||||||
|
destroy: ReturnType<typeof vi.fn>;
|
||||||
|
write: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAndCaptureClientErrorHandler() {
|
||||||
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
const { clientErrorHandler } = mockSuccessfulServe();
|
||||||
|
const running = await startApi(validConfig());
|
||||||
|
const handler = clientErrorHandler();
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error("Expected startApi to register a clientError handler.");
|
||||||
|
}
|
||||||
|
return { handler, running };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reproduces Node's own client-error responses byte-for-byte, since registering a listener suppresses them", async () => {
|
||||||
|
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [code, expected] of [
|
||||||
|
["ERR_HTTP_REQUEST_TIMEOUT", "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n"],
|
||||||
|
[
|
||||||
|
"HPE_HEADER_OVERFLOW",
|
||||||
|
"HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
|
||||||
|
],
|
||||||
|
["HPE_INVALID_METHOD", "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
|
||||||
|
[undefined, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
|
||||||
|
] as const) {
|
||||||
|
const socket = fakeErrorSocket();
|
||||||
|
const error = Object.assign(new Error("parser detail that must never be logged"), { code });
|
||||||
|
handler(error, socket);
|
||||||
|
|
||||||
|
expect(socket.write).toHaveBeenCalledTimes(1);
|
||||||
|
expect(socket.write).toHaveBeenCalledWith(expected);
|
||||||
|
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
await running.shutdown();
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never writes a second response when a handler already began responding, but still closes the socket", async () => {
|
||||||
|
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = fakeErrorSocket({ headersSent: true });
|
||||||
|
handler(Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }), socket);
|
||||||
|
|
||||||
|
expect(socket.write).not.toHaveBeenCalled();
|
||||||
|
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// An unwritable socket is likewise never written to, only destroyed.
|
||||||
|
const closedSocket = fakeErrorSocket({ writable: false });
|
||||||
|
handler(
|
||||||
|
Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }),
|
||||||
|
closedSocket,
|
||||||
|
);
|
||||||
|
expect(closedSocket.write).not.toHaveBeenCalled();
|
||||||
|
expect(closedSocket.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await running.shutdown();
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs one fixed sanitized line per client error, never the caught error's own message", async () => {
|
||||||
|
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
handler(
|
||||||
|
Object.assign(new Error("Parse Error: /secret-path?token=abc123"), {
|
||||||
|
code: "HPE_INVALID_METHOD",
|
||||||
|
}),
|
||||||
|
fakeErrorSocket(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
|
||||||
|
expect(emitted).not.toContain("secret-path");
|
||||||
|
expect(emitted).not.toContain("abc123");
|
||||||
|
expect(emitted).not.toContain("Parse Error");
|
||||||
|
expect(emitted).not.toContain("HPE_");
|
||||||
|
expect(emitted).not.toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/u);
|
||||||
|
|
||||||
|
await running.shutdown();
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Ekklesia API module permission composition at startup", () => {
|
describe("Ekklesia API module permission composition at startup", () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -577,7 +946,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();
|
||||||
@@ -613,18 +982,59 @@ 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("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();
|
||||||
@@ -659,6 +1069,48 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives a module's HTTP handler the exact same database facade object its own start hook received", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const refs = createReferenceRecorder();
|
||||||
|
await createFixtureModule(modulesDirectory, "db-identity", {
|
||||||
|
permissions: [{ baseline: "public", description: "Ping.", id: "db-identity.ping.read" }],
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(modulesDirectory, "db-identity", "dist/server/index.js"),
|
||||||
|
`export default {
|
||||||
|
id: "db-identity",
|
||||||
|
version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/ping",
|
||||||
|
permission: "db-identity.ping.read",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler: async (_request, context) => {
|
||||||
|
${refs.expression}.push(context.services.database);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
start(context) {
|
||||||
|
${refs.expression}.push(context.services.database);
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
);
|
||||||
|
mockPoolAndAuth();
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||||
|
const response = await running.app.request("/api/v1/modules/db-identity/ping");
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(refs.values).toHaveLength(2);
|
||||||
|
expect(refs.values[0]).toBeDefined();
|
||||||
|
expect(refs.values[0]).toBe(refs.values[1]);
|
||||||
|
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
await createFixtureModule(modulesDirectory, "broken-export");
|
await createFixtureModule(modulesDirectory, "broken-export");
|
||||||
@@ -715,11 +1167,14 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
|
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The module whose start hook failed is rolled back first through its own stop hook, then the
|
||||||
|
// module that had already started, in reverse order.
|
||||||
expect(recorder.events).toEqual([
|
expect(recorder.events).toEqual([
|
||||||
"one:register",
|
"one:register",
|
||||||
"two:register",
|
"two:register",
|
||||||
"one:start",
|
"one:start",
|
||||||
"two:start",
|
"two:start",
|
||||||
|
"two:stop",
|
||||||
"one:stop",
|
"one:stop",
|
||||||
]);
|
]);
|
||||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
@@ -772,6 +1227,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() });
|
||||||
@@ -789,6 +1245,31 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
|
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives a module's stop hook the exact same database facade object its start hook received, while the pool is still open", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const refs = createReferenceRecorder();
|
||||||
|
await createFixtureModule(modulesDirectory, "db-stop-identity");
|
||||||
|
await writeFile(
|
||||||
|
path.join(modulesDirectory, "db-stop-identity", "dist/server/index.js"),
|
||||||
|
`export default {
|
||||||
|
id: "db-stop-identity",
|
||||||
|
version: "0.1.0",
|
||||||
|
start(context) { ${refs.expression}.push(context.services.database); },
|
||||||
|
stop(context) { ${refs.expression}.push(context.services.database); }
|
||||||
|
};`,
|
||||||
|
);
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||||
|
await running.shutdown();
|
||||||
|
|
||||||
|
expect(refs.values).toHaveLength(2);
|
||||||
|
expect(refs.values[0]).toBeDefined();
|
||||||
|
expect(refs.values[0]).toBe(refs.values[1]);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
@@ -800,8 +1281,14 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
|
|
||||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||||
|
|
||||||
|
// Shutdown now aggregates which stages failed; the module-runtime error is carried intact.
|
||||||
await expect(running.shutdown()).rejects.toMatchObject({
|
await expect(running.shutdown()).rejects.toMatchObject({
|
||||||
|
drainTimedOut: false,
|
||||||
|
moduleStopError: {
|
||||||
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
|
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
|
||||||
|
},
|
||||||
|
moduleStopFailed: true,
|
||||||
|
name: "ApiShutdownError",
|
||||||
});
|
});
|
||||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -844,4 +1331,154 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
expect(serveMock).not.toHaveBeenCalled();
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass 4 correction: the API composition's own bounded-cleanup guarantees.
|
||||||
|
*
|
||||||
|
* Core proves an individual hook deadline in isolation; these prove that `startApi`/`shutdown`
|
||||||
|
* actually reach pool closure through those deadlines, which is where the platform's promise ("the
|
||||||
|
* pool always closes") is either kept or broken. Every deadline here is a short, test-only
|
||||||
|
* override; production keeps the 10-second hook and 30-second drain defaults.
|
||||||
|
*/
|
||||||
|
describe("Ekklesia API bounded module cleanup", () => {
|
||||||
|
it("bounds a never-settling startup rollback, still rolls back earlier modules, closes the pool, and binds no listener", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
|
||||||
|
dependencies: { modules: { required: { one: "^0.1.0" } } },
|
||||||
|
startThrows: true,
|
||||||
|
stopNeverSettles: true,
|
||||||
|
});
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
startApi(validConfig(), {
|
||||||
|
moduleHookTimeoutMsOverride: 25,
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
issues: [
|
||||||
|
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "two" }),
|
||||||
|
expect.objectContaining({ code: "MODULE_ROLLBACK_TIMEOUT", moduleId: "two" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The failing module's own stop hook was attempted first and timed out; the earlier module
|
||||||
|
// still received its rollback afterwards.
|
||||||
|
expect(recorder.events).toEqual([
|
||||||
|
"one:register",
|
||||||
|
"two:register",
|
||||||
|
"one:start",
|
||||||
|
"two:start",
|
||||||
|
"two:stop",
|
||||||
|
"one:stop",
|
||||||
|
]);
|
||||||
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds a never-settling normal stop, attempts the earlier module, and still closes the pool", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
|
||||||
|
dependencies: { modules: { required: { one: "^0.1.0" } } },
|
||||||
|
stopNeverSettles: true,
|
||||||
|
});
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), {
|
||||||
|
moduleHookTimeoutMsOverride: 25,
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(running.shutdown()).rejects.toMatchObject({
|
||||||
|
drainTimedOut: false,
|
||||||
|
moduleStopError: {
|
||||||
|
issues: [expect.objectContaining({ code: "MODULE_STOP_TIMEOUT", moduleId: "two" })],
|
||||||
|
},
|
||||||
|
moduleStopFailed: true,
|
||||||
|
name: "ApiShutdownError",
|
||||||
|
});
|
||||||
|
expect(recorder.events.slice(-2)).toEqual(["two:stop", "one:stop"]);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Repeated and concurrent callers share the one memoized result and restart nothing.
|
||||||
|
const [first, second] = await Promise.allSettled([running.shutdown(), running.shutdown()]);
|
||||||
|
expect(first.status).toBe("rejected");
|
||||||
|
expect(second.status).toBe("rejected");
|
||||||
|
expect(recorder.events.filter((event) => event === "two:stop")).toHaveLength(1);
|
||||||
|
expect(recorder.events.filter((event) => event === "one:stop")).toHaveLength(1);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a drain timeout and a module-stop timeout together, after the pool has closed", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "stalls", recorder, {
|
||||||
|
stopNeverSettles: true,
|
||||||
|
});
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
// A server whose `close` callback never fires stands in for a connection that never drains,
|
||||||
|
// deterministically and without a real socket. `closeIdleConnections`/`closeAllConnections`
|
||||||
|
// are the supported public APIs the platform calls at the deadline.
|
||||||
|
const closeAllConnections = vi.fn();
|
||||||
|
const closeIdleConnections = vi.fn();
|
||||||
|
const { server } = fakeServer();
|
||||||
|
// `close` deliberately never invokes its callback: nothing drains, which is exactly what the
|
||||||
|
// drain deadline exists for — deterministic, with no real socket and no sleep.
|
||||||
|
const stalled = Object.assign(server, {
|
||||||
|
close: vi.fn(() => server),
|
||||||
|
closeAllConnections,
|
||||||
|
closeIdleConnections,
|
||||||
|
});
|
||||||
|
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
|
||||||
|
// Deferred exactly like `mockSuccessfulServe`: `bindServer` resolves with `server`, which
|
||||||
|
// is still in its temporal dead zone during the synchronous `serve()` call.
|
||||||
|
queueMicrotask(() => listeningListener?.());
|
||||||
|
return stalled;
|
||||||
|
});
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), {
|
||||||
|
drainTimeoutMsOverride: 25,
|
||||||
|
moduleHookTimeoutMsOverride: 25,
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
await running.shutdown();
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(captured).toMatchObject({
|
||||||
|
drainTimedOut: true,
|
||||||
|
moduleStopFailed: true,
|
||||||
|
name: "ApiShutdownError",
|
||||||
|
});
|
||||||
|
const issues = (captured as { moduleStopError: { issues: Array<{ code: string }> } })
|
||||||
|
.moduleStopError.issues;
|
||||||
|
expect(issues).toEqual([
|
||||||
|
expect.objectContaining({ code: "MODULE_STOP_TIMEOUT", moduleId: "stalls" }),
|
||||||
|
]);
|
||||||
|
// The platform force-closed through Node's public APIs, then still stopped modules and closed
|
||||||
|
// the pool — neither deadline skipped a later stage.
|
||||||
|
expect(closeIdleConnections).toHaveBeenCalledTimes(1);
|
||||||
|
expect(closeAllConnections).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recorder.events).toContain("stalls:stop");
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Nothing about the request, socket, connection, or module exception leaks.
|
||||||
|
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}\n${JSON.stringify(
|
||||||
|
captured,
|
||||||
|
)}`;
|
||||||
|
for (const secret of ["127.0.0.1", "postgres", "password", "select ", "Error: stop"]) {
|
||||||
|
expect(publicText.toLowerCase()).not.toContain(secret.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,800 @@
|
|||||||
|
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 {
|
||||||
|
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(),
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,780 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { createApiDatabase } from "../src/database.js";
|
||||||
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
|
||||||
|
* (`core/tests/fixtures/module-database-integration/db-facade/`) whose lifecycle hooks and
|
||||||
|
* permissioned HTTP handlers perform real typed Drizzle operations — insert, select, update,
|
||||||
|
* transaction, execute — against its own migrated table, exclusively through the facade the
|
||||||
|
* platform hands it. Follows the exact harness conventions `apps/api/tests/api-http.integration.ts`
|
||||||
|
* and `core/tests/postgres-migration.integration.ts` already establish (self-contained per-file
|
||||||
|
* helpers, one disposable PostgreSQL database per scenario, `describe.sequential` blocks sharing a
|
||||||
|
* running API across related assertions) rather than introducing a second integration-test
|
||||||
|
* framework or Docker orchestration path.
|
||||||
|
*
|
||||||
|
* The fixture module is committed under `core/tests/fixtures/...`, not `apps/api/tests/fixtures/`,
|
||||||
|
* for one concrete reason: its schema imports `drizzle-orm/pg-core` and `@ekklesia/contracts`,
|
||||||
|
* neither of which `@ekklesia/api` declares as a direct dependency (pnpm's strict linker will not
|
||||||
|
* resolve them from anywhere under `apps/api/`), while `@ekklesia/core` already depends on both
|
||||||
|
* directly, so any file physically located under `core/`'s directory tree resolves them correctly
|
||||||
|
* regardless of which package's test actually points discovery at it. It is never registered as a
|
||||||
|
* pnpm workspace package (it matches none of `pnpm-workspace.yaml`'s glob patterns), never sits in
|
||||||
|
* the repository's live `modules/` directory, and is only ever discovered when this file explicitly
|
||||||
|
* passes its directory as `modulesDirectoryOverride` — normal production startup, Doctor, and
|
||||||
|
* `pnpm modules:validate` never see it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const thisDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const coreMigrationsDirectory = path.join(thisDirectory, "..", "..", "..", "core", "drizzle");
|
||||||
|
const fixtureModulesDirectory = path.join(
|
||||||
|
thisDirectory,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"core",
|
||||||
|
"tests",
|
||||||
|
"fixtures",
|
||||||
|
"module-database-integration",
|
||||||
|
);
|
||||||
|
|
||||||
|
const RECORDS_TABLE = "db_facade_records";
|
||||||
|
const RECORDER_GLOBAL_KEY = "ekklesia.integration.db-facade.recorder";
|
||||||
|
// Must match `STARTUP_RECORD_ID` in the fixture's `server/index.js` exactly.
|
||||||
|
const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111";
|
||||||
|
|
||||||
|
const identityBaseUrl = "https://identity.example.test";
|
||||||
|
const dashboardOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function harnessError(message: string): IntegrationHarnessError {
|
||||||
|
return new IntegrationHarnessError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
|
throw harnessError("The module database integration database is not configured.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||||
|
throw new TypeError("Unsupported protocol.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw harnessError("The module database integration database configuration is invalid.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteDatabaseName(databaseName: string): string {
|
||||||
|
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||||
|
throw harnessError("The generated integration database name is invalid.");
|
||||||
|
}
|
||||||
|
return `"${databaseName}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
function makeDatabaseName(label: string): string {
|
||||||
|
const safeLabel = label
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||||
|
.replaceAll(/^_+|_+$/gu, "")
|
||||||
|
.slice(0, 14);
|
||||||
|
databaseCounter += 1;
|
||||||
|
return `ekk_dbf_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomUUID().slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(databaseName: string): string {
|
||||||
|
const parsed = new URL(baseDatabaseUrl());
|
||||||
|
parsed.pathname = `/${databaseName}`;
|
||||||
|
parsed.searchParams.delete("application_name");
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString });
|
||||||
|
client.on("error", () => undefined);
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
// The public harness failure remains static when connection cleanup also fails.
|
||||||
|
}
|
||||||
|
throw harnessError("The module database integration connection failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<Row extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: unknown[] = [],
|
||||||
|
): Promise<readonly Row[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<Row>(text, values);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
throw harnessError("An integration database administration operation failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeEnd(client: Client): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
throw harnessError("An integration database connection could not be closed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
|
||||||
|
async function createDatabase(databaseName: string): Promise<void> {
|
||||||
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropDatabase(databaseName: string): Promise<void> {
|
||||||
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||||
|
[databaseName],
|
||||||
|
);
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||||
|
);
|
||||||
|
trackedDatabases.delete(databaseName);
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activeConnectionCount(databaseName: string): Promise<number> {
|
||||||
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ count: string }>(
|
||||||
|
admin,
|
||||||
|
"SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1",
|
||||||
|
[databaseName],
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.count ?? "0");
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||||
|
const name = makeDatabaseName(label);
|
||||||
|
await createDatabase(name);
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
return { name, url: databaseUrl(name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies only Core's own migrations, against an empty modules directory — the module fixture's
|
||||||
|
* migration is deliberately absent, used only by the "no auto-migration" negative scenario below. */
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModulesDirectory = await mkdtemp(
|
||||||
|
path.join(tmpdir(), "ekklesia-db-facade-no-modules-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
|
"core:0000_identity_core_tables",
|
||||||
|
"core:0001_authorization_core_tables",
|
||||||
|
"core:0002_authorization_integrity",
|
||||||
|
"core:0003_dashboard_navigation_tables",
|
||||||
|
"core:0004_module_lifecycle_state",
|
||||||
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
]);
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies Core's migrations plus the `db-facade` fixture module's own migration — explicitly,
|
||||||
|
* before any module runtime exists — proving the platform never infers or auto-generates this
|
||||||
|
* table. Pass-1's committed core migrations and checksums must not have drifted while this pass
|
||||||
|
* was built. */
|
||||||
|
async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: fixtureModulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
||||||
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
|
"core:0000_identity_core_tables",
|
||||||
|
"core:0001_authorization_core_tables",
|
||||||
|
"core:0002_authorization_integrity",
|
||||||
|
"core:0003_dashboard_navigation_tables",
|
||||||
|
"core:0004_module_lifecycle_state",
|
||||||
|
"core:0005_module_configuration",
|
||||||
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
"db-facade:0000_initial",
|
||||||
|
]);
|
||||||
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
|
);
|
||||||
|
expect(aggregate.migrations[1]?.checksum).toBe(
|
||||||
|
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
||||||
|
);
|
||||||
|
expect(aggregate.migrations[2]?.checksum).toBe(
|
||||||
|
"63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036",
|
||||||
|
);
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMigratedApi(
|
||||||
|
url: string,
|
||||||
|
modulesDirectoryOverride?: string,
|
||||||
|
): Promise<RunningApi> {
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
publicSignupEnabled: true,
|
||||||
|
secret: "integration-test-secret-0123456789abcdefgh",
|
||||||
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
},
|
||||||
|
// Development validation: this suite signs an owner account up over HTTP to exercise real
|
||||||
|
// authorized module database access, and production configuration refuses to enable public
|
||||||
|
// signup at all until durable abuse controls exist
|
||||||
|
// (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`). `environment`
|
||||||
|
// affects only Ekklesia's own config validation — it never reaches Better Auth — and the base
|
||||||
|
// URL and trusted origins here are identical either way.
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
return startApi(config, {
|
||||||
|
rateLimitOverride: { max: 1000, window: 1 },
|
||||||
|
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiOrigin(running: RunningApi): string {
|
||||||
|
const address = running.server.address();
|
||||||
|
if (!address || typeof address !== "object") {
|
||||||
|
throw harnessError("The API integration server has no bound address.");
|
||||||
|
}
|
||||||
|
return `http://127.0.0.1:${address.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignUpResult {
|
||||||
|
readonly userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signUp(origin: string, email: string, password: string): Promise<SignUpResult> {
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||||
|
body: JSON.stringify({ email, name: "Integration Test User", password }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as { user: { id: string } };
|
||||||
|
return { userId: body.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
|
||||||
|
*
|
||||||
|
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
|
||||||
|
* module database access rather than identity, so it completes that step directly rather than
|
||||||
|
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
|
||||||
|
* `email-verification.integration.ts`.
|
||||||
|
*/
|
||||||
|
async function markEmailVerified(url: string, email: string): Promise<void> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const setCookie = response.headers.get("set-cookie");
|
||||||
|
if (!setCookie) {
|
||||||
|
throw harnessError("Sign-in did not return a session cookie.");
|
||||||
|
}
|
||||||
|
return setCookie.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordedEvent {
|
||||||
|
readonly type: "register" | "start" | "stop" | "handler";
|
||||||
|
readonly services?: { readonly database: unknown };
|
||||||
|
readonly route?: string;
|
||||||
|
readonly contextKeys?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalRecord(): Record<string, unknown> {
|
||||||
|
return globalThis as unknown as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRecorder(): void {
|
||||||
|
globalRecord()[RECORDER_GLOBAL_KEY] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecorder(): RecordedEvent[] {
|
||||||
|
return (globalRecord()[RECORDER_GLOBAL_KEY] as RecordedEvent[] | undefined) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(url: string): Promise<boolean> {
|
||||||
|
const admin = await openAdministrativeClient(url);
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ exists: boolean }>(
|
||||||
|
admin,
|
||||||
|
"select exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = $1) as exists",
|
||||||
|
[RECORDS_TABLE],
|
||||||
|
);
|
||||||
|
return rows[0]?.exists ?? false;
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecordRow(
|
||||||
|
url: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<{ id: string; value: string } | undefined> {
|
||||||
|
const admin = await openAdministrativeClient(url);
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ id: string; value: string }>(
|
||||||
|
admin,
|
||||||
|
`select id, value from "${RECORDS_TABLE}" where id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordExists(url: string, id: string): Promise<boolean> {
|
||||||
|
return (await fetchRecordRow(url, id)) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrationHistoryCount(url: string, identity: string): Promise<number> {
|
||||||
|
const admin = await openAdministrativeClient(url);
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ count: string }>(
|
||||||
|
admin,
|
||||||
|
`select count(*)::text as count from "ekklesia_core_migrations" where identity = $1`,
|
||||||
|
[identity],
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.count ?? "0");
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete globalRecord()[RECORDER_GLOBAL_KEY];
|
||||||
|
for (const databaseName of [...trackedDatabases]) {
|
||||||
|
await dropDatabase(databaseName).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("live API — module database explicit migration ownership", () => {
|
||||||
|
it("creates no table merely by applying core migrations or constructing the pool and facade — only an explicit module migration does", async () => {
|
||||||
|
const database = await createDisposableDatabase("mig_absent");
|
||||||
|
try {
|
||||||
|
expect(await tableExists(database.url)).toBe(false);
|
||||||
|
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
expect(await tableExists(database.url)).toBe(false);
|
||||||
|
|
||||||
|
const handle = createApiDatabase(database.url);
|
||||||
|
expect(handle.module).toBeDefined();
|
||||||
|
await handle.close();
|
||||||
|
expect(await tableExists(database.url)).toBe(false);
|
||||||
|
|
||||||
|
await applyMigrationsWithModule(database.url);
|
||||||
|
expect(await tableExists(database.url)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await dropDatabase(database.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the module's migration exactly once, never regenerating or reapplying it across repeated startups", async () => {
|
||||||
|
const database = await createDisposableDatabase("mig_once");
|
||||||
|
try {
|
||||||
|
await applyMigrationsWithModule(database.url);
|
||||||
|
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
|
||||||
|
|
||||||
|
const first = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||||
|
await first.shutdown();
|
||||||
|
const second = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||||
|
await second.shutdown();
|
||||||
|
|
||||||
|
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
|
||||||
|
expect(await migrationHistoryCount(database.url, "core:0000_identity_core_tables")).toBe(1);
|
||||||
|
expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
|
||||||
|
).toBe(1);
|
||||||
|
expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await dropDatabase(database.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails module startup rather than silently creating the table when the module's own migration was never applied", async () => {
|
||||||
|
const database = await createDisposableDatabase("mig_skip");
|
||||||
|
try {
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
resetRecorder();
|
||||||
|
|
||||||
|
// The module whose `start` failed is now rolled back through its own `stop` hook first. That
|
||||||
|
// hook also writes to the table its missing migration would have created, so it fails too —
|
||||||
|
// which is exactly the documented reason a `stop` hook must tolerate partial initialization.
|
||||||
|
// Both outcomes are reported, in order, and neither is swallowed.
|
||||||
|
await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({
|
||||||
|
issues: [
|
||||||
|
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" }),
|
||||||
|
expect.objectContaining({ code: "MODULE_ROLLBACK_FAILED", moduleId: "db-facade" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// No schema inference, no automatic table creation as a side effect of the failed attempt.
|
||||||
|
expect(await tableExists(database.url)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await dropDatabase(database.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("live API — module database access through ModuleRuntimeServices", () => {
|
||||||
|
let database: { name: string; url: string };
|
||||||
|
let running: RunningApi;
|
||||||
|
let origin: string;
|
||||||
|
let ownerCookie: string;
|
||||||
|
|
||||||
|
it("starts a freshly migrated API; register receives no services; start writes the startup record through the real facade", async () => {
|
||||||
|
database = await createDisposableDatabase("db_facade");
|
||||||
|
await applyMigrationsWithModule(database.url);
|
||||||
|
resetRecorder();
|
||||||
|
|
||||||
|
running = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||||
|
origin = apiOrigin(running);
|
||||||
|
|
||||||
|
const events = readRecorder();
|
||||||
|
const registerEvents = events.filter((event) => event.type === "register");
|
||||||
|
const startEvents = events.filter((event) => event.type === "start");
|
||||||
|
expect(registerEvents).toHaveLength(1);
|
||||||
|
expect(registerEvents[0]?.services).toBeUndefined();
|
||||||
|
expect(startEvents).toHaveLength(1);
|
||||||
|
expect(startEvents[0]?.services?.database).toBeDefined();
|
||||||
|
|
||||||
|
const startupRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
|
||||||
|
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||||
|
|
||||||
|
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
||||||
|
await markEmailVerified(database.url, "[email protected]");
|
||||||
|
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
||||||
|
expect(signedUp.userId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authorized read reflects real PostgreSQL state, exposes the exact scoped handler context, and the handler's database is reference-equal to start's", async () => {
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||||
|
|
||||||
|
const events = readRecorder();
|
||||||
|
const startEvent = events.find((event) => event.type === "start");
|
||||||
|
const readHandlerEvent = [...events]
|
||||||
|
.reverse()
|
||||||
|
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||||
|
// The complete handler-context surface, asserted exactly so a new field is a deliberate change
|
||||||
|
// here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime`
|
||||||
|
// (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono
|
||||||
|
// Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or
|
||||||
|
// credential — remains unreachable.
|
||||||
|
expect(readHandlerEvent?.contextKeys).toEqual([
|
||||||
|
"config",
|
||||||
|
"moduleId",
|
||||||
|
"params",
|
||||||
|
"principal",
|
||||||
|
"realtime",
|
||||||
|
"services",
|
||||||
|
"storage",
|
||||||
|
]);
|
||||||
|
expect(readHandlerEvent?.services).toBeDefined();
|
||||||
|
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a trusted Origin for the module's mutating route, performs a real insert, and a later read observes it", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const noOrigin = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||||
|
body: JSON.stringify({ id, value: "no-origin" }),
|
||||||
|
headers: { "content-type": "application/json", cookie: ownerCookie },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(noOrigin.status).toBe(403);
|
||||||
|
expect(await noOrigin.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect(await recordExists(database.url, id)).toBe(false);
|
||||||
|
|
||||||
|
const created = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||||
|
body: JSON.stringify({ id, value: "created-value" }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
expect(await created.json()).toEqual({ id, value: "created-value" });
|
||||||
|
|
||||||
|
const readBack = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`);
|
||||||
|
expect(readBack.status).toBe(200);
|
||||||
|
expect(await readBack.json()).toEqual({ id, value: "created-value" });
|
||||||
|
|
||||||
|
const patched = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`, {
|
||||||
|
body: JSON.stringify({ value: "patched-value" }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "PATCH",
|
||||||
|
});
|
||||||
|
expect(patched.status).toBe(200);
|
||||||
|
expect(await patched.json()).toEqual({ id, value: "patched-value" });
|
||||||
|
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "patched-value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies the mutating route to an unauthenticated caller, invoking neither the handler nor a database mutation", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const eventsBefore = readRecorder().length;
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||||
|
body: JSON.stringify({ id, value: "should-not-persist" }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
expect(readRecorder().length).toBe(eventsBefore);
|
||||||
|
expect(await recordExists(database.url, id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an untrusted-Origin mutation before principal resolution, invoking neither the handler nor a database mutation", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const eventsBefore = readRecorder().length;
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||||
|
body: JSON.stringify({ id, value: "should-not-persist" }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: untrustedOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect(readRecorder().length).toBe(eventsBefore);
|
||||||
|
expect(await recordExists(database.url, id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps OPTIONS platform-owned: never invokes module code, never mutates the table, and returns correct final CORS/Vary", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const eventsBefore = readRecorder().length;
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||||
|
headers: {
|
||||||
|
"access-control-request-method": "POST",
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
||||||
|
const vary = (response.headers.get("vary") ?? "").split(",").map((token) => token.trim());
|
||||||
|
expect(vary.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
||||||
|
expect(readRecorder().length).toBe(eventsBefore);
|
||||||
|
expect(await recordExists(database.url, id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot let HEAD bypass the authorized GET chain, and creates no mutation", async () => {
|
||||||
|
const anonymousHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||||
|
method: "HEAD",
|
||||||
|
});
|
||||||
|
expect(anonymousHead.status).toBe(403);
|
||||||
|
expect(await anonymousHead.text()).toBe("");
|
||||||
|
|
||||||
|
const authorizedHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
method: "HEAD",
|
||||||
|
});
|
||||||
|
expect(authorizedHead.status).toBe(200);
|
||||||
|
expect(await authorizedHead.text()).toBe("");
|
||||||
|
|
||||||
|
const countBefore = await countRecordRowsViaGet(origin, ownerCookie);
|
||||||
|
const countAfter = await countRecordRowsViaGet(origin, ownerCookie);
|
||||||
|
expect(countAfter).toBe(countBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a real transaction and persists its result", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
|
||||||
|
body: JSON.stringify({ id, value: "committed-value" }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(await response.json()).toEqual({ committed: true });
|
||||||
|
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "committed-value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a real transaction on a unique-constraint failure, leaving no partial mutation", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const conflictId = randomUUID();
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
|
||||||
|
body: JSON.stringify({ id, value: "should-roll-back", conflictId }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toEqual({ committed: false });
|
||||||
|
|
||||||
|
// The whole transaction rolled back atomically — including the first, otherwise-valid insert.
|
||||||
|
expect(await recordExists(database.url, id)).toBe(false);
|
||||||
|
expect(await recordExists(database.url, conflictId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a module's own sanitized response for a database failure it deliberately catches", async () => {
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/safe-boom`);
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toEqual({ code: "BOOM_HANDLED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes an uncaught real PostgreSQL failure to the existing MODULE_HANDLER_FAILED boundary, leaking no detail", async () => {
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/boom`);
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(text).toBe(JSON.stringify({ code: "MODULE_HANDLER_FAILED" }));
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
|
||||||
|
const lowered = text.toLowerCase();
|
||||||
|
for (const forbidden of [
|
||||||
|
"division",
|
||||||
|
"sql",
|
||||||
|
"postgres",
|
||||||
|
"22012",
|
||||||
|
"select",
|
||||||
|
"relation",
|
||||||
|
"stack",
|
||||||
|
"node_modules",
|
||||||
|
fixtureModulesDirectory.toLowerCase(),
|
||||||
|
]) {
|
||||||
|
expect(lowered).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops cleanly: stop receives the same services and database as start, its write completes before the pool closes, and the API shuts down", async () => {
|
||||||
|
const events = readRecorder();
|
||||||
|
const startEvent = events.find((event) => event.type === "start");
|
||||||
|
|
||||||
|
await running.shutdown();
|
||||||
|
|
||||||
|
const remainingConnections = await activeConnectionCount(database.name);
|
||||||
|
expect(remainingConnections).toBe(0);
|
||||||
|
|
||||||
|
const eventsAfterShutdown = readRecorder();
|
||||||
|
const stopEvents = eventsAfterShutdown.filter((event) => event.type === "stop");
|
||||||
|
expect(stopEvents).toHaveLength(1);
|
||||||
|
expect(stopEvents[0]?.services?.database).toBe(startEvent?.services?.database);
|
||||||
|
|
||||||
|
// Read via a separate administrative connection, entirely independent of the API's own
|
||||||
|
// (now-closed) pool — proving the stop write completed and persisted before that pool closed.
|
||||||
|
const stoppedRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
|
||||||
|
expect(stoppedRow).toEqual({ id: STARTUP_RECORD_ID, value: "stopped-by-shutdown" });
|
||||||
|
|
||||||
|
await dropDatabase(database.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function countRecordRowsViaGet(origin: string, cookie: string): Promise<number> {
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||||
|
headers: { cookie },
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as { count: number };
|
||||||
|
return body.count;
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
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 { 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(),
|
||||||
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||||
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||||
|
: {}),
|
||||||
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeServices(),
|
||||||
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
moduleUi,
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, modules };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(): EkklesiaPrincipal {
|
||||||
|
return createAuthenticatedPrincipal(viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module display routes — authorization and descriptor", () => {
|
||||||
|
it("gives an unpaired viewer the inert shell but never the descriptor", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const [page, descriptor] = await Promise.all([
|
||||||
|
app.request(moduleDisplayPageUrl("announcements")),
|
||||||
|
app.request(moduleDisplayDescriptorUrl("announcements")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The page is deliberately open: a television nobody has paired has no identity to present, and
|
||||||
|
// a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing —
|
||||||
|
// the same module-agnostic bytes every display gets, with no module id and no data.
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(await page.text()).toBe(displayHostAssets.html);
|
||||||
|
|
||||||
|
// Authorization is entirely in the descriptor, and it has not moved.
|
||||||
|
expect(descriptor.status).toBe(403);
|
||||||
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
||||||
|
const { app } = await install({ display: true, principal: signedIn() });
|
||||||
|
|
||||||
|
// Being signed in is not being permitted, and the descriptor is where that is decided.
|
||||||
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
version: "1.4.2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await app.request(moduleDisplayPageUrl("announcements"));
|
||||||
|
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||||
|
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
expect(await page.text()).toBe(displayHostAssets.html);
|
||||||
|
expect(await descriptor.json()).toEqual({
|
||||||
|
viewer: { kind: "authenticated", userId: viewerId },
|
||||||
|
module: {
|
||||||
|
contributions: [],
|
||||||
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
|
display: {
|
||||||
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
|
},
|
||||||
|
id: "announcements",
|
||||||
|
page: { url: moduleDisplayPageUrl("announcements") },
|
||||||
|
permissions: [displayPermission],
|
||||||
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||||
|
version: "1.4.2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(descriptor.headers.get("content-type")).toContain("application/json");
|
||||||
|
expect(descriptor.headers.get("vary")).toContain("Origin");
|
||||||
|
expect(descriptor.headers.get("vary")).toContain("Cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
|
||||||
|
const authorize = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ allowed: true })
|
||||||
|
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
|
||||||
|
const evaluator: AuthorizationEvaluator = { authorize };
|
||||||
|
const { app } = await install({ display: true, evaluator, principal: signedIn() });
|
||||||
|
|
||||||
|
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(authorize).toHaveBeenCalledTimes(2);
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("vary")).toContain("Origin");
|
||||||
|
expect(response.headers.get("vary")).toContain("Cookie");
|
||||||
|
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
|
||||||
|
expect(body).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the outer page stable when the internal artifact version changes", () => {
|
||||||
|
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
|
||||||
|
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module display routes — role and lifecycle separation", () => {
|
||||||
|
it("keeps a display-only module out of the primary UI inventory", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect(await inventory.json()).toEqual({ modules: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not give a dashboard-only module a display route", async () => {
|
||||||
|
const { app } = await install({ principal: signedIn(), ui: true });
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||||
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||||
|
expect(inventory.status).toBe(200);
|
||||||
|
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
|
||||||
|
modules: [{ id: "announcements" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes both roles independently when a module declares both", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
ui: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
|
modules: Array<{ display: { url: string } | null }>;
|
||||||
|
}
|
||||||
|
).modules,
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
published: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||||
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(
|
||||||
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers only the exact stable page and descriptor paths", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const route of [
|
||||||
|
"/display",
|
||||||
|
"/display/announcements/",
|
||||||
|
"/display/announcements/settings",
|
||||||
|
"/api/v1/module-displays",
|
||||||
|
"/api/v1/module-displays/announcements/",
|
||||||
|
"/api/v1/module-displays/announcements/settings",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(route)).status, route).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails composition when a display exists but its host assets were not supplied", async () => {
|
||||||
|
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
|
||||||
|
ModuleDisplayConfigurationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module display host asset publication", () => {
|
||||||
|
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.script);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
expect(await notModified.text()).toBe("");
|
||||||
|
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
|
||||||
|
expect(notModified.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
|
||||||
|
for (const header of [
|
||||||
|
`W/${displayHostAssets.scriptEtag}`,
|
||||||
|
"*",
|
||||||
|
`"stale", ${displayHostAssets.scriptEtag}`,
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": header },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
header,
|
||||||
|
).toBe(304);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "if-none-match": '"stale"' },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
|
||||||
|
"/assets/ekklesia-display/index.html",
|
||||||
|
"/assets/ekklesia-display/secret.txt",
|
||||||
|
"/assets/ekklesia-display/../secret.txt",
|
||||||
|
"/api/v1/module-ui/announcements/1.4.2/management",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses identical non-interpolated HTML for every eligible module", async () => {
|
||||||
|
const first = await install({
|
||||||
|
display: true,
|
||||||
|
granted: [displayPermission],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
const second = await install({
|
||||||
|
display: true,
|
||||||
|
granted: ["signage.display.view"],
|
||||||
|
id: "signage",
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
|
||||||
|
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
|
||||||
|
expect(firstHtml).toBe(secondHtml);
|
||||||
|
expect(firstHtml).not.toContain("announcements");
|
||||||
|
expect(secondHtml).not.toContain("signage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
||||||
|
const source = createDisplayHostAssets(
|
||||||
|
"<html>display host</html>",
|
||||||
|
"export const host = 1;\n",
|
||||||
|
"export const worker = 1;\n",
|
||||||
|
);
|
||||||
|
const mutable = { ...source };
|
||||||
|
const { app } = await install({ assets: mutable, display: true });
|
||||||
|
mutable.html = "<html>mutated secret</html>";
|
||||||
|
mutable.script = "throw new Error('mutated secret');";
|
||||||
|
|
||||||
|
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
install({
|
||||||
|
assets: { ...source, scriptEtag: '"stale-validator"' },
|
||||||
|
display: true,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the generic host script when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the offline service worker from the path whose directory is its scope", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
|
||||||
|
|
||||||
|
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
|
||||||
|
// else. The Dashboard is never controlled by it (ADR 0043).
|
||||||
|
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
|
||||||
|
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates the service worker with its own validator, never the host script's", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
|
||||||
|
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(wrongValidator.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the service worker when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be reached through a neighbouring or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
|
||||||
|
"/display/sw.js/",
|
||||||
|
"/sw.js",
|
||||||
|
"/assets/ekklesia-display/sw.js",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,578 @@
|
|||||||
|
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 { 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(),
|
||||||
|
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,775 @@
|
|||||||
|
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 { 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(),
|
||||||
|
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,630 @@
|
|||||||
|
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 { 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(),
|
||||||
|
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,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>() };
|
||||||
|
}
|
||||||
@@ -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,13 @@
|
|||||||
|
CREATE TABLE "ekklesia_core_platform_owner_bootstrap" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"consumed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"consumed_for_user_id" text,
|
||||||
|
CONSTRAINT "ekklesia_core_platform_owner_bootstrap_singleton_check" CHECK ("ekklesia_core_platform_owner_bootstrap"."id" = 'singleton')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_authorization_audit" DROP CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "ekklesia_core_platform_owner_bootstrap" ("id")
|
||||||
|
SELECT 'singleton'
|
||||||
|
WHERE EXISTS (SELECT 1 FROM "ekklesia_core_platform_owner")
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE "ekklesia_core_dashboard_group" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "ekklesia_core_dashboard_group_id_check" CHECK (length("ekklesia_core_dashboard_group"."id") > 0),
|
||||||
|
CONSTRAINT "ekklesia_core_dashboard_group_label_check" CHECK (length(btrim("ekklesia_core_dashboard_group"."label")) > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ekklesia_core_dashboard_placement" (
|
||||||
|
"module_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"group_id" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "ekklesia_core_dashboard_placement_module_check" CHECK (length("ekklesia_core_dashboard_placement"."module_id") > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_dashboard_placement" ADD CONSTRAINT "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."ekklesia_core_dashboard_group"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_dashboard_group_order_idx" ON "ekklesia_core_dashboard_group" USING btree ("sort_order","id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_dashboard_placement_group_idx" ON "ekklesia_core_dashboard_placement" USING btree ("group_id","sort_order");
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE "ekklesia_core_module_state" (
|
||||||
|
"module_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"enabled" boolean NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "ekklesia_core_module_state_module_check" CHECK (length("ekklesia_core_module_state"."module_id") > 0)
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "ekklesia_core_module_configuration" (
|
||||||
|
"module_id" text NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "ekklesia_core_module_configuration_module_id_key_pk" PRIMARY KEY("module_id","key"),
|
||||||
|
CONSTRAINT "ekklesia_core_module_configuration_module_check" CHECK (length("ekklesia_core_module_configuration"."module_id") > 0),
|
||||||
|
CONSTRAINT "ekklesia_core_module_configuration_key_check" CHECK (length("ekklesia_core_module_configuration"."key") > 0),
|
||||||
|
CONSTRAINT "ekklesia_core_module_configuration_value_check" CHECK (length("ekklesia_core_module_configuration"."value") <= 2048)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_module_configuration_module_idx" ON "ekklesia_core_module_configuration" USING btree ("module_id");
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
CREATE TABLE "ekklesia_core_device" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"credential_hash" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_by_user_id" text,
|
||||||
|
"last_seen_at" timestamp with time zone,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
"revoked_by_user_id" text,
|
||||||
|
CONSTRAINT "ekklesia_core_device_name_check" CHECK (length("ekklesia_core_device"."name") between 1 and 128),
|
||||||
|
CONSTRAINT "ekklesia_core_device_credential_hash_check" CHECK (length("ekklesia_core_device"."credential_hash") = 64)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ekklesia_core_device_pairing_request" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"claim_secret_hash" text NOT NULL,
|
||||||
|
"module_id" text NOT NULL,
|
||||||
|
"permission_id" text NOT NULL,
|
||||||
|
"state" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"device_name" text,
|
||||||
|
"approved_at" timestamp with time zone,
|
||||||
|
"approved_by_user_id" text,
|
||||||
|
"consumed_at" timestamp with time zone,
|
||||||
|
"device_id" uuid,
|
||||||
|
CONSTRAINT "ekklesia_core_device_pairing_code_check" CHECK ("ekklesia_core_device_pairing_request"."code" ~ '^[0-9]{6}$'),
|
||||||
|
CONSTRAINT "ekklesia_core_device_pairing_claim_hash_check" CHECK (length("ekklesia_core_device_pairing_request"."claim_secret_hash") = 64),
|
||||||
|
CONSTRAINT "ekklesia_core_device_pairing_state_check" CHECK ("ekklesia_core_device_pairing_request"."state" in ('pending', 'approved', 'consumed'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ekklesia_core_device_permission" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"device_id" uuid NOT NULL,
|
||||||
|
"permission_id" text NOT NULL,
|
||||||
|
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"granted_by_user_id" text,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "ekklesia_core_device_permission_id_check" CHECK (length("ekklesia_core_device_permission"."permission_id") between 1 and 128)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("revoked_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("approved_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("granted_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "ekklesia_core_device_credential_hash_idx" ON "ekklesia_core_device" USING btree ("credential_hash");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_device_revoked_idx" ON "ekklesia_core_device" USING btree ("revoked_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "ekklesia_core_device_pairing_code_idx" ON "ekklesia_core_device_pairing_request" USING btree ("code") WHERE "ekklesia_core_device_pairing_request"."state" <> 'consumed';--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_device_pairing_claim_idx" ON "ekklesia_core_device_pairing_request" USING btree ("claim_secret_hash");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ekklesia_core_device_permission_lookup_idx" ON "ekklesia_core_device_permission" USING btree ("device_id","permission_id");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Email verification becomes a precondition for holding a session (ADR 0038). Any platform owner
|
||||||
|
-- that already exists was created by the trusted operator-run bootstrap command before that command
|
||||||
|
-- marked addresses verified, so without this they would be locked out of their own installation by
|
||||||
|
-- an upgrade.
|
||||||
|
--
|
||||||
|
-- Deliberately narrow. It marks exactly the identities the authorization tables already record as
|
||||||
|
-- currently-granted platform owners: an authority the deployment established through a command that
|
||||||
|
-- required database credentials, which is the same authority the bootstrap verification exception
|
||||||
|
-- rests on. Every other account is left untouched and must verify by email like any other.
|
||||||
|
--
|
||||||
|
-- Data-only and idempotent. It adds no column, drops nothing, and re-running it changes no row that
|
||||||
|
-- is not already false.
|
||||||
|
UPDATE "ekklesia_core_user"
|
||||||
|
SET "email_verified" = true
|
||||||
|
WHERE "email_verified" = false
|
||||||
|
AND "id" IN (
|
||||||
|
SELECT "user_id"
|
||||||
|
FROM "ekklesia_core_platform_owner"
|
||||||
|
WHERE "revoked_at" IS NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
{
|
||||||
|
"id": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
|
||||||
|
"prevId": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.ekklesia_core_account": {
|
||||||
|
"name": "ekklesia_core_account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_account_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_account_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_account",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_session": {
|
||||||
|
"name": "ekklesia_core_session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_session_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_session_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_session",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_session_token_unique": {
|
||||||
|
"name": "ekklesia_core_session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["token"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_user": {
|
||||||
|
"name": "ekklesia_core_user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_user_email_unique": {
|
||||||
|
"name": "ekklesia_core_user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["email"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_verification": {
|
||||||
|
"name": "ekklesia_core_verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_verification_identifier_idx": {
|
||||||
|
"name": "ekklesia_core_verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_authorization_audit": {
|
||||||
|
"name": "ekklesia_core_authorization_audit",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_type": {
|
||||||
|
"name": "actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_id": {
|
||||||
|
"name": "actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"subject_user_id": {
|
||||||
|
"name": "subject_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"grant_id": {
|
||||||
|
"name": "grant_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_authorization_audit_subject_idx": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "subject_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_authorization_audit_action_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_action_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_scope_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_grant": {
|
||||||
|
"name": "ekklesia_core_grant",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"source_kind": {
|
||||||
|
"name": "source_kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source_ref": {
|
||||||
|
"name": "source_ref",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"created_by_actor_type": {
|
||||||
|
"name": "created_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by_actor_id": {
|
||||||
|
"name": "created_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_grant_user_permission_idx": {
|
||||||
|
"name": "ekklesia_core_grant_user_permission_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "permission_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_grant",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_grant_source_kind_check": {
|
||||||
|
"name": "ekklesia_core_grant_source_kind_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_created_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner": {
|
||||||
|
"name": "ekklesia_core_platform_owner",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_at": {
|
||||||
|
"name": "granted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"granted_by_actor_type": {
|
||||||
|
"name": "granted_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_by_actor_id": {
|
||||||
|
"name": "granted_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_platform_owner",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"consumed_at": {
|
||||||
|
"name": "consumed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"consumed_for_user_id": {
|
||||||
|
"name": "consumed_for_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
{
|
||||||
|
"id": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
|
||||||
|
"prevId": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.ekklesia_core_account": {
|
||||||
|
"name": "ekklesia_core_account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_account_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_account_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_account",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_session": {
|
||||||
|
"name": "ekklesia_core_session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_session_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_session_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_session",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_session_token_unique": {
|
||||||
|
"name": "ekklesia_core_session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["token"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_user": {
|
||||||
|
"name": "ekklesia_core_user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_user_email_unique": {
|
||||||
|
"name": "ekklesia_core_user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["email"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_verification": {
|
||||||
|
"name": "ekklesia_core_verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_verification_identifier_idx": {
|
||||||
|
"name": "ekklesia_core_verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_authorization_audit": {
|
||||||
|
"name": "ekklesia_core_authorization_audit",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_type": {
|
||||||
|
"name": "actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_id": {
|
||||||
|
"name": "actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"subject_user_id": {
|
||||||
|
"name": "subject_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"grant_id": {
|
||||||
|
"name": "grant_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_authorization_audit_subject_idx": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "subject_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_authorization_audit_action_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_action_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_scope_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_grant": {
|
||||||
|
"name": "ekklesia_core_grant",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"source_kind": {
|
||||||
|
"name": "source_kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source_ref": {
|
||||||
|
"name": "source_ref",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"created_by_actor_type": {
|
||||||
|
"name": "created_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by_actor_id": {
|
||||||
|
"name": "created_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_grant_user_permission_idx": {
|
||||||
|
"name": "ekklesia_core_grant_user_permission_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "permission_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_grant",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_grant_source_kind_check": {
|
||||||
|
"name": "ekklesia_core_grant_source_kind_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_created_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner": {
|
||||||
|
"name": "ekklesia_core_platform_owner",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_at": {
|
||||||
|
"name": "granted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"granted_by_actor_type": {
|
||||||
|
"name": "granted_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_by_actor_id": {
|
||||||
|
"name": "granted_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_platform_owner",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"consumed_at": {
|
||||||
|
"name": "consumed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"consumed_for_user_id": {
|
||||||
|
"name": "consumed_for_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_dashboard_group": {
|
||||||
|
"name": "ekklesia_core_dashboard_group",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"name": "label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_dashboard_group_order_idx": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "sort_order",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_dashboard_group_id_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_id_check",
|
||||||
|
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||||
|
},
|
||||||
|
"ekklesia_core_dashboard_group_label_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_label_check",
|
||||||
|
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_dashboard_placement": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"module_id": {
|
||||||
|
"name": "module_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"group_id": {
|
||||||
|
"name": "group_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_dashboard_placement_group_idx": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "group_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "sort_order",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||||
|
"tableTo": "ekklesia_core_dashboard_group",
|
||||||
|
"columnsFrom": ["group_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_dashboard_placement_module_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||||
|
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,914 @@
|
|||||||
|
{
|
||||||
|
"id": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
|
||||||
|
"prevId": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.ekklesia_core_account": {
|
||||||
|
"name": "ekklesia_core_account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_account_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_account_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_account",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_session": {
|
||||||
|
"name": "ekklesia_core_session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_session_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_session_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_session",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_session_token_unique": {
|
||||||
|
"name": "ekklesia_core_session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["token"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_user": {
|
||||||
|
"name": "ekklesia_core_user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_user_email_unique": {
|
||||||
|
"name": "ekklesia_core_user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["email"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_verification": {
|
||||||
|
"name": "ekklesia_core_verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_verification_identifier_idx": {
|
||||||
|
"name": "ekklesia_core_verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_authorization_audit": {
|
||||||
|
"name": "ekklesia_core_authorization_audit",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_type": {
|
||||||
|
"name": "actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_id": {
|
||||||
|
"name": "actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"subject_user_id": {
|
||||||
|
"name": "subject_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"grant_id": {
|
||||||
|
"name": "grant_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_authorization_audit_subject_idx": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "subject_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_authorization_audit_action_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_action_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_scope_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_grant": {
|
||||||
|
"name": "ekklesia_core_grant",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"source_kind": {
|
||||||
|
"name": "source_kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source_ref": {
|
||||||
|
"name": "source_ref",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"created_by_actor_type": {
|
||||||
|
"name": "created_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by_actor_id": {
|
||||||
|
"name": "created_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_grant_user_permission_idx": {
|
||||||
|
"name": "ekklesia_core_grant_user_permission_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "permission_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_grant",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_grant_source_kind_check": {
|
||||||
|
"name": "ekklesia_core_grant_source_kind_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_created_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner": {
|
||||||
|
"name": "ekklesia_core_platform_owner",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_at": {
|
||||||
|
"name": "granted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"granted_by_actor_type": {
|
||||||
|
"name": "granted_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_by_actor_id": {
|
||||||
|
"name": "granted_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_platform_owner",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"consumed_at": {
|
||||||
|
"name": "consumed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"consumed_for_user_id": {
|
||||||
|
"name": "consumed_for_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_dashboard_group": {
|
||||||
|
"name": "ekklesia_core_dashboard_group",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"name": "label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_dashboard_group_order_idx": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "sort_order",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_dashboard_group_id_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_id_check",
|
||||||
|
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||||
|
},
|
||||||
|
"ekklesia_core_dashboard_group_label_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_label_check",
|
||||||
|
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_dashboard_placement": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"module_id": {
|
||||||
|
"name": "module_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"group_id": {
|
||||||
|
"name": "group_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_dashboard_placement_group_idx": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "group_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "sort_order",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||||
|
"tableTo": "ekklesia_core_dashboard_group",
|
||||||
|
"columnsFrom": ["group_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_dashboard_placement_module_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||||
|
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_module_state": {
|
||||||
|
"name": "ekklesia_core_module_state",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"module_id": {
|
||||||
|
"name": "module_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_module_state_module_check": {
|
||||||
|
"name": "ekklesia_core_module_state_module_check",
|
||||||
|
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,993 @@
|
|||||||
|
{
|
||||||
|
"id": "d41e490c-dbe1-4d73-9e11-6e79baa91908",
|
||||||
|
"prevId": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.ekklesia_core_account": {
|
||||||
|
"name": "ekklesia_core_account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_account_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_account_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_account",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_session": {
|
||||||
|
"name": "ekklesia_core_session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_session_user_id_idx": {
|
||||||
|
"name": "ekklesia_core_session_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_session",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_session_token_unique": {
|
||||||
|
"name": "ekklesia_core_session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["token"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_user": {
|
||||||
|
"name": "ekklesia_core_user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"ekklesia_core_user_email_unique": {
|
||||||
|
"name": "ekklesia_core_user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["email"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_verification": {
|
||||||
|
"name": "ekklesia_core_verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_verification_identifier_idx": {
|
||||||
|
"name": "ekklesia_core_verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_authorization_audit": {
|
||||||
|
"name": "ekklesia_core_authorization_audit",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_type": {
|
||||||
|
"name": "actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"actor_id": {
|
||||||
|
"name": "actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"subject_user_id": {
|
||||||
|
"name": "subject_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"grant_id": {
|
||||||
|
"name": "grant_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_authorization_audit_subject_idx": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "subject_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_authorization_audit_action_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_action_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_authorization_audit_scope_check": {
|
||||||
|
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||||
|
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_grant": {
|
||||||
|
"name": "ekklesia_core_grant",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"permission_id": {
|
||||||
|
"name": "permission_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"source_kind": {
|
||||||
|
"name": "source_kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source_ref": {
|
||||||
|
"name": "source_ref",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"created_by_actor_type": {
|
||||||
|
"name": "created_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by_actor_id": {
|
||||||
|
"name": "created_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_grant_user_permission_idx": {
|
||||||
|
"name": "ekklesia_core_grant_user_permission_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "permission_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_grant",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_grant_source_kind_check": {
|
||||||
|
"name": "ekklesia_core_grant_source_kind_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_created_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner": {
|
||||||
|
"name": "ekklesia_core_platform_owner",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_at": {
|
||||||
|
"name": "granted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"granted_by_actor_type": {
|
||||||
|
"name": "granted_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"granted_by_actor_id": {
|
||||||
|
"name": "granted_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_type": {
|
||||||
|
"name": "revoked_by_actor_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"revoked_by_actor_id": {
|
||||||
|
"name": "revoked_by_actor_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||||
|
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_platform_owner",
|
||||||
|
"tableTo": "ekklesia_core_user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||||
|
},
|
||||||
|
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_platform_owner_bootstrap": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"consumed_at": {
|
||||||
|
"name": "consumed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"consumed_for_user_id": {
|
||||||
|
"name": "consumed_for_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
|
||||||
|
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
|
||||||
|
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_dashboard_group": {
|
||||||
|
"name": "ekklesia_core_dashboard_group",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"name": "label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_dashboard_group_order_idx": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_order_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "sort_order",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_dashboard_group_id_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_id_check",
|
||||||
|
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
|
||||||
|
},
|
||||||
|
"ekklesia_core_dashboard_group_label_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_group_label_check",
|
||||||
|
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_dashboard_placement": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"module_id": {
|
||||||
|
"name": "module_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"group_id": {
|
||||||
|
"name": "group_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_dashboard_placement_group_idx": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_group_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "group_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "sort_order",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
|
||||||
|
"tableFrom": "ekklesia_core_dashboard_placement",
|
||||||
|
"tableTo": "ekklesia_core_dashboard_group",
|
||||||
|
"columnsFrom": ["group_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_dashboard_placement_module_check": {
|
||||||
|
"name": "ekklesia_core_dashboard_placement_module_check",
|
||||||
|
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_module_state": {
|
||||||
|
"name": "ekklesia_core_module_state",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"module_id": {
|
||||||
|
"name": "module_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_module_state_module_check": {
|
||||||
|
"name": "ekklesia_core_module_state_module_check",
|
||||||
|
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ekklesia_core_module_configuration": {
|
||||||
|
"name": "ekklesia_core_module_configuration",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"module_id": {
|
||||||
|
"name": "module_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ekklesia_core_module_configuration_module_idx": {
|
||||||
|
"name": "ekklesia_core_module_configuration_module_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "module_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"ekklesia_core_module_configuration_module_id_key_pk": {
|
||||||
|
"name": "ekklesia_core_module_configuration_module_id_key_pk",
|
||||||
|
"columns": ["module_id", "key"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"ekklesia_core_module_configuration_module_check": {
|
||||||
|
"name": "ekklesia_core_module_configuration_module_check",
|
||||||
|
"value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0"
|
||||||
|
},
|
||||||
|
"ekklesia_core_module_configuration_key_check": {
|
||||||
|
"name": "ekklesia_core_module_configuration_key_check",
|
||||||
|
"value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0"
|
||||||
|
},
|
||||||
|
"ekklesia_core_module_configuration_value_check": {
|
||||||
|
"name": "ekklesia_core_module_configuration_value_check",
|
||||||
|
"value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,42 @@
|
|||||||
"destructive": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0002_authorization_integrity",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0003_dashboard_navigation_tables",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0004_module_lifecycle_state",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0005_module_configuration",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0006_device_identity",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0007_verified_bootstrap_owners",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,48 @@
|
|||||||
"when": 1784819635300,
|
"when": 1784819635300,
|
||||||
"tag": "0001_authorization_core_tables",
|
"tag": "0001_authorization_core_tables",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785104670124,
|
||||||
|
"tag": "0002_authorization_integrity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787408382271,
|
||||||
|
"tag": "0003_dashboard_navigation_tables",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787423121678,
|
||||||
|
"tag": "0004_module_lifecycle_state",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787451496425,
|
||||||
|
"tag": "0005_module_configuration",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787543773057,
|
||||||
|
"tag": "0006_device_identity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787543773058,
|
||||||
|
"tag": "0007_verified_bootstrap_owners",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { EkklesiaUserId } from "@ekklesia/contracts";
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||||
import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js";
|
import {
|
||||||
|
ekklesiaCoreAuthorizationAudit,
|
||||||
|
ekklesiaCorePlatformOwner,
|
||||||
|
ekklesiaCorePlatformOwnerBootstrap,
|
||||||
|
PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
export type PlatformOwnerBootstrapErrorCode =
|
export type PlatformOwnerBootstrapErrorCode =
|
||||||
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||||
@@ -54,17 +59,25 @@ export interface BootstrapPlatformOwnerInput {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
|
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
|
||||||
* composition of "check, then call the existing grant function": the single-use check, the target
|
* composition of "check, then call the existing grant function": the single-use claim, the target
|
||||||
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
|
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
|
||||||
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
|
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
|
||||||
* statement — so two concurrent callers can never both observe "no owner yet" and both insert.
|
* statement — so two concurrent callers can never both observe "not yet consumed" and both insert.
|
||||||
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
|
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
|
||||||
* re-evaluates against the now-current state.
|
* re-evaluates against the now-current state.
|
||||||
*
|
*
|
||||||
* "Already consumed" is global and permanent: it is true the moment
|
* **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued
|
||||||
* `ekklesia_core_platform_owner` has ever held any row for any user, including one whose
|
* existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so
|
||||||
* `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a
|
* treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's
|
||||||
* platform owner would ever be re-established after a revocation, not this command.
|
* user row silently reopened the one operation that establishes a platform's first administrator —
|
||||||
|
* along with the audit evidence that it had ever run. The dedicated singleton has no user foreign
|
||||||
|
* key, is never deleted by Core, and therefore survives owner revocation and user deletion alike.
|
||||||
|
*
|
||||||
|
* Consumption is deliberately tied to *success*: the sentinel is written inside the same
|
||||||
|
* transaction as the owner row and its audit entry, so a missing target user, a constraint
|
||||||
|
* violation, or any other failure rolls the sentinel back with everything else and leaves the one
|
||||||
|
* legitimate first use still available. There is no reset command, deletion path, HTTP endpoint,
|
||||||
|
* environment override, or force flag — by design, "consumed" is permanent.
|
||||||
*/
|
*/
|
||||||
export async function bootstrapPlatformOwner(
|
export async function bootstrapPlatformOwner(
|
||||||
db: AuthorizationDrizzleDatabase,
|
db: AuthorizationDrizzleDatabase,
|
||||||
@@ -76,11 +89,11 @@ export async function bootstrapPlatformOwner(
|
|||||||
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingOwnerRows = await tx
|
const consumedRows = await tx
|
||||||
.select({ userId: ekklesiaCorePlatformOwner.userId })
|
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||||
.from(ekklesiaCorePlatformOwner)
|
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existingOwnerRows.length > 0) {
|
if (consumedRows.length > 0) {
|
||||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
|
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +106,15 @@ export async function bootstrapPlatformOwner(
|
|||||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
|
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sentinel is claimed inside the same transaction as everything below it. The advisory
|
||||||
|
// lock already serializes concurrent callers; the singleton primary key is the independent,
|
||||||
|
// structural backstop that makes a second row impossible even if a future caller reached
|
||||||
|
// this insert without the lock.
|
||||||
|
await tx.insert(ekklesiaCorePlatformOwnerBootstrap).values({
|
||||||
|
consumedForUserId: input.userId,
|
||||||
|
id: PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
|
||||||
|
});
|
||||||
|
|
||||||
await tx.insert(ekklesiaCorePlatformOwner).values({
|
await tx.insert(ekklesiaCorePlatformOwner).values({
|
||||||
grantedByActorId: BOOTSTRAP_ACTOR_ID,
|
grantedByActorId: BOOTSTRAP_ACTOR_ID,
|
||||||
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
|
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import { Pool, type PoolConfig } from "pg";
|
import { Pool, type PoolConfig } from "pg";
|
||||||
|
import {
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
databaseTlsConnectionOptions,
|
||||||
|
} from "../database/transport.js";
|
||||||
import { authorizationSchema } from "./schema.js";
|
import { authorizationSchema } from "./schema.js";
|
||||||
|
|
||||||
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
|
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
|
||||||
@@ -27,6 +31,10 @@ export interface AuthorizationDatabaseOptions {
|
|||||||
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
|
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
|
||||||
* connection, not a hung query already running on one. */
|
* connection, not a hung query already running on one. */
|
||||||
readonly operationTimeoutMs?: number;
|
readonly operationTimeoutMs?: number;
|
||||||
|
/** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by
|
||||||
|
* every production executable; omitted by single-domain unit/integration factories that use
|
||||||
|
* the disposable loopback database, which keeps the pre-existing plaintext behavior. */
|
||||||
|
readonly transport?: DatabaseTransportPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthorizationDatabaseHandle {
|
export interface AuthorizationDatabaseHandle {
|
||||||
@@ -80,8 +88,13 @@ export function createAuthorizationDrizzleDatabase(
|
|||||||
const validatedConnectionString = validateConnectionString(connectionString);
|
const validatedConnectionString = validateConnectionString(connectionString);
|
||||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||||
|
const ssl =
|
||||||
|
options.transport === undefined
|
||||||
|
? undefined
|
||||||
|
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
|
||||||
const poolConfig: PoolConfig = {
|
const poolConfig: PoolConfig = {
|
||||||
connectionString: validatedConnectionString,
|
connectionString: validatedConnectionString,
|
||||||
|
...(ssl === undefined ? {} : { ssl }),
|
||||||
...(validatedConnectionTimeoutMs === undefined
|
...(validatedConnectionTimeoutMs === undefined
|
||||||
? {}
|
? {}
|
||||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,11 +72,20 @@ export const ekklesiaCorePlatformOwner = pgTable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append-only: no update or delete path is ever exposed by Core's authorization store. `scope`
|
* Append-only *by application convention*: Core's authorization store exposes no update or delete
|
||||||
* intentionally rejects an empty (but non-null) string — defense in depth matching the
|
* path for these rows. That is a property of this codebase, not a database guarantee — no
|
||||||
* application-layer `AuthorizationScope` contract, which never produces one — so a coding defect
|
* PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or
|
||||||
* that slipped an empty scope past that validation fails the whole grant-plus-audit transaction
|
* anything else holding the same credentials from modifying them. `scope` intentionally rejects an
|
||||||
* atomically instead of recording a meaningless audit entry.
|
* empty (but non-null) string — defense in depth matching the application-layer
|
||||||
|
* `AuthorizationScope` contract, which never produces one — so a coding defect that slipped an
|
||||||
|
* empty scope past that validation fails the whole grant-plus-audit transaction atomically instead
|
||||||
|
* of recording a meaningless audit entry.
|
||||||
|
*
|
||||||
|
* `subject_user_id` is a **retained historical snapshot**, deliberately carrying no foreign key to
|
||||||
|
* the live Better Auth user row. It previously cascaded on user deletion, which meant deleting a
|
||||||
|
* user destroyed exactly the security history describing what that user had been granted and who
|
||||||
|
* granted it — the audit trail disappeared precisely when it mattered most. The value stays a
|
||||||
|
* plain, non-null identifier string; no name, email, or other profile data is ever copied here.
|
||||||
*/
|
*/
|
||||||
export const ekklesiaCoreAuthorizationAudit = pgTable(
|
export const ekklesiaCoreAuthorizationAudit = pgTable(
|
||||||
"ekklesia_core_authorization_audit",
|
"ekklesia_core_authorization_audit",
|
||||||
@@ -85,9 +94,7 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
|
|||||||
action: text("action").notNull(),
|
action: text("action").notNull(),
|
||||||
actorType: text("actor_type").notNull(),
|
actorType: text("actor_type").notNull(),
|
||||||
actorId: text("actor_id"),
|
actorId: text("actor_id"),
|
||||||
subjectUserId: text("subject_user_id")
|
subjectUserId: text("subject_user_id").notNull(),
|
||||||
.notNull()
|
|
||||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
|
||||||
permissionId: text("permission_id"),
|
permissionId: text("permission_id"),
|
||||||
scope: text("scope"),
|
scope: text("scope"),
|
||||||
grantId: uuid("grant_id"),
|
grantId: uuid("grant_id"),
|
||||||
@@ -110,6 +117,40 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The durable, single-row record meaning "platform-owner bootstrap was successfully consumed."
|
||||||
|
*
|
||||||
|
* Bootstrap's single-use authority previously came from the mere existence of a row in
|
||||||
|
* `ekklesia_core_platform_owner` — which cascades on user deletion, so deleting the bootstrapped
|
||||||
|
* owner's user row silently reopened the one-time operation that establishes the platform's first
|
||||||
|
* administrator. This table exists specifically so that authority survives owner revocation and
|
||||||
|
* user deletion: it has **no foreign key to any user**, and nothing in Core ever deletes from it.
|
||||||
|
*
|
||||||
|
* `id` is pinned by a check constraint to the single literal value `'singleton'`, so the primary
|
||||||
|
* key makes a second row structurally impossible rather than merely unwritten by convention —
|
||||||
|
* concurrent first attempts collide on the primary key inside their transactions.
|
||||||
|
*
|
||||||
|
* `consumed_for_user_id` is an optional historical snapshot of who bootstrap made an owner, in the
|
||||||
|
* same retained-identifier style as the audit subject and carrying no profile data. It is null for
|
||||||
|
* an installation whose consumed state was backfilled by migration, because a backfill cannot know
|
||||||
|
* which of several historical owner rows the original single use actually targeted, and guessing
|
||||||
|
* one would fabricate history.
|
||||||
|
*/
|
||||||
|
export const ekklesiaCorePlatformOwnerBootstrap = pgTable(
|
||||||
|
"ekklesia_core_platform_owner_bootstrap",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
consumedAt: timestamp("consumed_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
consumedForUserId: text("consumed_for_user_id"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check("ekklesia_core_platform_owner_bootstrap_singleton_check", sql`${table.id} = 'singleton'`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The only value `ekklesia_core_platform_owner_bootstrap.id` may ever hold. */
|
||||||
|
export const PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID = "singleton";
|
||||||
|
|
||||||
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
|
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
|
||||||
user: one(ekklesiaCoreUser, {
|
user: one(ekklesiaCoreUser, {
|
||||||
fields: [ekklesiaCoreGrant.userId],
|
fields: [ekklesiaCoreGrant.userId],
|
||||||
@@ -127,19 +168,14 @@ export const ekklesiaCorePlatformOwnerRelations = relations(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ekklesiaCoreAuthorizationAuditRelations = relations(
|
// `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table:
|
||||||
ekklesiaCoreAuthorizationAudit,
|
// its subject is a retained historical identifier that may name a user row that no longer exists,
|
||||||
({ one }) => ({
|
// so modelling it as a live relation would misdescribe the data.
|
||||||
subject: one(ekklesiaCoreUser, {
|
|
||||||
fields: [ekklesiaCoreAuthorizationAudit.subjectUserId],
|
|
||||||
references: [ekklesiaCoreUser.id],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
|
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
|
||||||
export const authorizationSchema = {
|
export const authorizationSchema = {
|
||||||
authorizationAudit: ekklesiaCoreAuthorizationAudit,
|
authorizationAudit: ekklesiaCoreAuthorizationAudit,
|
||||||
grant: ekklesiaCoreGrant,
|
grant: ekklesiaCoreGrant,
|
||||||
platformOwner: ekklesiaCorePlatformOwner,
|
platformOwner: ekklesiaCorePlatformOwner,
|
||||||
|
platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+289
-24
@@ -28,7 +28,10 @@ import {
|
|||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
export type AuthorizationStoreErrorCode =
|
export type AuthorizationStoreErrorCode =
|
||||||
|
| "AUTHORIZATION_GRANT_ALREADY_EXISTS"
|
||||||
| "AUTHORIZATION_GRANT_CREATE_FAILED"
|
| "AUTHORIZATION_GRANT_CREATE_FAILED"
|
||||||
|
| "AUTHORIZATION_GRANT_EXPIRATION_INVALID"
|
||||||
|
| "AUTHORIZATION_GRANT_LIMIT_INVALID"
|
||||||
| "AUTHORIZATION_GRANT_LIST_FAILED"
|
| "AUTHORIZATION_GRANT_LIST_FAILED"
|
||||||
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
|
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
|
||||||
| "AUTHORIZATION_GRANT_NOT_FOUND"
|
| "AUTHORIZATION_GRANT_NOT_FOUND"
|
||||||
@@ -38,7 +41,12 @@ export type AuthorizationStoreErrorCode =
|
|||||||
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
|
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
|
||||||
|
|
||||||
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
||||||
|
AUTHORIZATION_GRANT_ALREADY_EXISTS:
|
||||||
|
"An equivalent authorization grant is already effective for this user.",
|
||||||
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
||||||
|
AUTHORIZATION_GRANT_EXPIRATION_INVALID:
|
||||||
|
"The authorization grant expiration must be a valid instant in the future.",
|
||||||
|
AUTHORIZATION_GRANT_LIMIT_INVALID: "The authorization grant page size is invalid.",
|
||||||
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
|
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
|
||||||
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
|
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
|
||||||
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
|
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
|
||||||
@@ -49,6 +57,10 @@ const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>>
|
|||||||
"The platform-owner record was not found or is already revoked.",
|
"The platform-owner record was not found or is already revoked.",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The largest page any caller may request from `listGrantsForUser`, enforced identically by the
|
||||||
|
* store and by the HTTP surface so there is exactly one bound, not two that could drift. */
|
||||||
|
export const AUTHORIZATION_GRANT_LIST_MAX_LIMIT = 100;
|
||||||
|
|
||||||
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||||
export class AuthorizationStoreError extends Error {
|
export class AuthorizationStoreError extends Error {
|
||||||
readonly code: AuthorizationStoreErrorCode;
|
readonly code: AuthorizationStoreErrorCode;
|
||||||
@@ -101,11 +113,38 @@ export interface RevokeGrantInput {
|
|||||||
readonly sourceKind?: GrantSourceKind;
|
readonly sourceKind?: GrantSourceKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row's position in the `created_at DESC, id DESC` ordering, used as a strict keyset
|
||||||
|
* boundary. Both values are required: comparing on the timestamp alone would skip or repeat rows
|
||||||
|
* whenever two grants share a `created_at`, which concurrent creation makes routine. */
|
||||||
|
export interface GrantPageBoundary {
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly id: GrantId;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListGrantsForUserOptions {
|
export interface ListGrantsForUserOptions {
|
||||||
|
/** Required. The maximum number of rows to return, 1 through
|
||||||
|
* `AUTHORIZATION_GRANT_LIST_MAX_LIMIT`. There is deliberately no default and no unbounded
|
||||||
|
* fallback anywhere: an unbounded listing returned a user's entire authorization history in one
|
||||||
|
* response, and a bound a caller may simply omit is not a bound. */
|
||||||
|
readonly limit: number;
|
||||||
|
/** When provided, only grants strictly *after* this position in the ordering are returned. */
|
||||||
|
readonly after?: GrantPageBoundary;
|
||||||
/** When provided, only grants with this exact `sourceKind` are returned. */
|
/** When provided, only grants with this exact `sourceKind` are returned. */
|
||||||
readonly sourceKind?: GrantSourceKind;
|
readonly sourceKind?: GrantSourceKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GrantPage {
|
||||||
|
readonly grants: readonly GrantRecord[];
|
||||||
|
/** The last returned row's position when more rows exist, otherwise `undefined`. */
|
||||||
|
readonly nextBoundary?: GrantPageBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAuthorizationStoreOptions {
|
||||||
|
/** Injected only so expiration validation is deterministic under test. Production composition
|
||||||
|
* leaves this unset and the store reads real time. */
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
|
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
|
||||||
* append-only audit log, all behind one explicit factory — no environment reads, no connection
|
* append-only audit log, all behind one explicit factory — no environment reads, no connection
|
||||||
@@ -117,12 +156,9 @@ export interface ListGrantsForUserOptions {
|
|||||||
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
|
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
|
||||||
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
|
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
|
||||||
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
||||||
/** Every grant ever recorded for the user, active or revoked, newest first — the caller decides
|
/** One bounded page of the user's grants, active or revoked, ordered `created_at DESC, id DESC`.
|
||||||
* what to display. Never includes another user's grants. */
|
* Never includes another user's grants. */
|
||||||
listGrantsForUser(
|
listGrantsForUser(userId: EkklesiaUserId, options: ListGrantsForUserOptions): Promise<GrantPage>;
|
||||||
userId: EkklesiaUserId,
|
|
||||||
options?: ListGrantsForUserOptions,
|
|
||||||
): Promise<readonly GrantRecord[]>;
|
|
||||||
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||||
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -160,11 +196,141 @@ function toGrantRecord(row: GrantRow): GrantRecord {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore {
|
/**
|
||||||
|
* The five values that make two grant issuances the *same authorization source*: subject,
|
||||||
|
* permission, scope, source kind, and source reference — with `NULL` equal only to `NULL`.
|
||||||
|
* `expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
|
||||||
|
* distinguish them: re-issuing the identical authority with a different expiry or by a different
|
||||||
|
* administrator is the same grant, not a second one.
|
||||||
|
*/
|
||||||
|
interface GrantEquivalence {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly permissionId: string;
|
||||||
|
readonly scope: string | null;
|
||||||
|
readonly sourceKind: string;
|
||||||
|
readonly sourceRef: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function equivalenceOf(input: CreateGrantInput): GrantEquivalence {
|
||||||
|
return {
|
||||||
|
permissionId: input.permission,
|
||||||
|
scope: input.scope ?? null,
|
||||||
|
sourceKind: input.sourceKind,
|
||||||
|
sourceRef: input.sourceRef ?? null,
|
||||||
|
userId: input.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes every create/revoke touching one equivalence tuple against a transaction-scoped
|
||||||
|
* PostgreSQL advisory lock, so concurrent callers cannot both observe "no effective equivalent"
|
||||||
|
* and both insert. A process-local mutex would not do this — nothing constrains the number of API
|
||||||
|
* processes — and neither would an unlocked check-then-insert.
|
||||||
|
*
|
||||||
|
* The key is a 64-bit hash of the tuple encoded as a JSON array, which keeps `NULL` and the empty
|
||||||
|
* string distinct and makes separator forgery through a crafted scope or source reference
|
||||||
|
* impossible. A hash collision is harmless by construction: it can only make two unrelated tuples
|
||||||
|
* serialize against each other, never make a real conflict go unnoticed, because the authoritative
|
||||||
|
* decision is always the exact SQL predicate below and never the hash.
|
||||||
|
*
|
||||||
|
* `pg_advisory_xact_lock(bigint)` uses PostgreSQL's *single-key* advisory-lock space, which is
|
||||||
|
* verified to be disjoint from the two-key space used by the migration runner
|
||||||
|
* (`core/src/migrations/postgres-database.ts`) and by platform-owner bootstrap. Grant serialization
|
||||||
|
* therefore can never block, or be blocked by, migration or bootstrap locking. Being
|
||||||
|
* transaction-scoped, it is released on COMMIT or ROLLBACK with no manual unlock.
|
||||||
|
*/
|
||||||
|
async function lockEquivalence(
|
||||||
|
tx: Parameters<Parameters<AuthorizationDrizzleDatabase["transaction"]>[0]>[0],
|
||||||
|
equivalence: GrantEquivalence,
|
||||||
|
): Promise<void> {
|
||||||
|
await tx.execute(
|
||||||
|
sql`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
|
||||||
|
${equivalence.userId}::text,
|
||||||
|
${equivalence.permissionId}::text,
|
||||||
|
${equivalence.scope}::text,
|
||||||
|
${equivalence.sourceKind}::text,
|
||||||
|
${equivalence.sourceRef}::text
|
||||||
|
)::text, 0))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches exactly the rows sharing an equivalence tuple. `is not distinct from` is what gives
|
||||||
|
* `NULL = NULL` for the two nullable members; plain `=` would silently match nothing. */
|
||||||
|
function equivalenceCondition(equivalence: GrantEquivalence) {
|
||||||
|
return and(
|
||||||
|
eq(ekklesiaCoreGrant.userId, equivalence.userId),
|
||||||
|
eq(ekklesiaCoreGrant.permissionId, equivalence.permissionId),
|
||||||
|
sql`${ekklesiaCoreGrant.scope} is not distinct from ${equivalence.scope}::text`,
|
||||||
|
eq(ekklesiaCoreGrant.sourceKind, equivalence.sourceKind),
|
||||||
|
sql`${ekklesiaCoreGrant.sourceRef} is not distinct from ${equivalence.sourceRef}::text`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A supplied expiration must be a real instant strictly later than the acceptance clock. A past
|
||||||
|
* or equal-to-now expiration produces a grant that is dead the moment it is written, which is
|
||||||
|
* never what a caller meant; rejecting it here means trusted internal callers get the same
|
||||||
|
* guarantee the HTTP surface enforces, rather than each validating separately. */
|
||||||
|
function assertValidExpiration(expiresAt: Date | undefined, now: Date): void {
|
||||||
|
if (expiresAt === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(expiresAt instanceof Date) ||
|
||||||
|
Number.isNaN(expiresAt.getTime()) ||
|
||||||
|
expiresAt.getTime() <= now.getTime()
|
||||||
|
) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_EXPIRATION_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidLimit(limit: number): number {
|
||||||
|
if (
|
||||||
|
typeof limit !== "number" ||
|
||||||
|
!Number.isSafeInteger(limit) ||
|
||||||
|
limit < 1 ||
|
||||||
|
limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT
|
||||||
|
) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIMIT_INVALID");
|
||||||
|
}
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthorizationStore(
|
||||||
|
db: AuthorizationDrizzleDatabase,
|
||||||
|
options: CreateAuthorizationStoreOptions = {},
|
||||||
|
): AuthorizationStore {
|
||||||
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
|
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
|
||||||
|
// Validated before any transaction opens, so a rejected expiration performs no database work
|
||||||
|
// at all and can never leave a grant or an audit row behind.
|
||||||
|
assertValidExpiration(input.expiresAt, now());
|
||||||
|
const equivalence = equivalenceOf(input);
|
||||||
try {
|
try {
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
|
await lockEquivalence(tx, equivalence);
|
||||||
|
|
||||||
|
// Under the lock, "currently effective" is authoritative: an equivalent that is revoked,
|
||||||
|
// or whose expiry has already passed, does not block a fresh issuance.
|
||||||
|
const effective = await tx
|
||||||
|
.select({ id: ekklesiaCoreGrant.id })
|
||||||
|
.from(ekklesiaCoreGrant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
equivalenceCondition(equivalence),
|
||||||
|
isNull(ekklesiaCoreGrant.revokedAt),
|
||||||
|
or(
|
||||||
|
isNull(ekklesiaCoreGrant.expiresAt),
|
||||||
|
gt(ekklesiaCoreGrant.expiresAt, sql`now()`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (effective.length > 0) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS");
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
.insert(ekklesiaCoreGrant)
|
.insert(ekklesiaCoreGrant)
|
||||||
.values({
|
.values({
|
||||||
@@ -202,16 +368,33 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoking one grant revokes *every* currently unrevoked issuance sharing its equivalence
|
||||||
|
* tuple, in one transaction, with one `grant.revoke` audit row per grant actually changed.
|
||||||
|
*
|
||||||
|
* Addressing a single id and revoking only that row was actively misleading: an operator who
|
||||||
|
* revoked a grant received `success` while an equivalent duplicate kept authorizing the same
|
||||||
|
* user. Expired-but-unrevoked equivalents are included deliberately — they are already
|
||||||
|
* ineffective, but leaving them unrevoked would keep the history in two states for what is one
|
||||||
|
* authorization source. Grants with a different subject, permission, scope, source kind, or
|
||||||
|
* source reference are untouched.
|
||||||
|
*/
|
||||||
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
|
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const [row] = await tx
|
// This first read exists only to discover the equivalence tuple, which is safe to read
|
||||||
.update(ekklesiaCoreGrant)
|
// before the lock because those columns are immutable once written. It deliberately does
|
||||||
.set({
|
// *not* settle whether the revocation may proceed: everything it observed can change
|
||||||
revokedAt: sql`now()`,
|
// while this transaction waits for the lock below.
|
||||||
revokedByActorId: actorId(input.actor),
|
const [target] = await tx
|
||||||
revokedByActorType: input.actor.type,
|
.select({
|
||||||
|
permissionId: ekklesiaCoreGrant.permissionId,
|
||||||
|
scope: ekklesiaCoreGrant.scope,
|
||||||
|
sourceKind: ekklesiaCoreGrant.sourceKind,
|
||||||
|
sourceRef: ekklesiaCoreGrant.sourceRef,
|
||||||
|
userId: ekklesiaCoreGrant.userId,
|
||||||
})
|
})
|
||||||
|
.from(ekklesiaCoreGrant)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(ekklesiaCoreGrant.id, input.grantId),
|
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||||
@@ -221,12 +404,65 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
|||||||
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.limit(1);
|
||||||
if (!row) {
|
if (!target) {
|
||||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
await lockEquivalence(tx, target);
|
||||||
|
|
||||||
|
// Revalidate the *addressed* grant now that the lock is held. Without this, a revocation
|
||||||
|
// that paused between the read above and the lock could wake after its target was
|
||||||
|
// revoked and a fresh equivalent created, and the tuple-matching update below would
|
||||||
|
// silently revoke that replacement — reporting success for a grant the caller never
|
||||||
|
// addressed, in an order no serial execution could produce, and losing the not-found
|
||||||
|
// result an already-revoked target must return.
|
||||||
|
//
|
||||||
|
// The check is deliberately narrow and re-asserts every condition the pre-lock read
|
||||||
|
// relied on: the exact addressed id, still unrevoked, still satisfying the caller's
|
||||||
|
// explicit/relationship source guard, and still carrying the same equivalence tuple the
|
||||||
|
// lock was taken for. It is a plain read, not `for update`: the tuple advisory lock
|
||||||
|
// remains the one create/revoke serialization order, so no second lock ordering — and no
|
||||||
|
// deadlock path — is introduced.
|
||||||
|
const [addressed] = await tx
|
||||||
|
.select({ id: ekklesiaCoreGrant.id })
|
||||||
|
.from(ekklesiaCoreGrant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||||
|
isNull(ekklesiaCoreGrant.revokedAt),
|
||||||
|
...(input.sourceKind === undefined
|
||||||
|
? []
|
||||||
|
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||||
|
equivalenceCondition(target),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!addressed) {
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only now is the whole equivalence class cleared, which is what still removes genuine
|
||||||
|
// legacy duplicates — including expired-but-unrevoked ones. Narrowing this update to the
|
||||||
|
// addressed id instead would not have been a fix; it would have dropped that behavior.
|
||||||
|
const rows = await tx
|
||||||
|
.update(ekklesiaCoreGrant)
|
||||||
|
.set({
|
||||||
|
revokedAt: sql`now()`,
|
||||||
|
revokedByActorId: actorId(input.actor),
|
||||||
|
revokedByActorType: input.actor.type,
|
||||||
|
})
|
||||||
|
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
|
||||||
|
.returning();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
// Unreachable while the lock is held, since the row confirmed above satisfies this
|
||||||
|
// same predicate — kept as a fail-closed guard against a writer that bypasses the
|
||||||
|
// lock, rather than silently auditing nothing.
|
||||||
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(ekklesiaCoreAuthorizationAudit).values(
|
||||||
|
rows.map((row) => ({
|
||||||
action: "grant.revoke",
|
action: "grant.revoke",
|
||||||
actorId: actorId(input.actor),
|
actorId: actorId(input.actor),
|
||||||
actorType: input.actor.type,
|
actorType: input.actor.type,
|
||||||
@@ -234,7 +470,8 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
|||||||
permissionId: row.permissionId,
|
permissionId: row.permissionId,
|
||||||
scope: row.scope,
|
scope: row.scope,
|
||||||
subjectUserId: row.userId,
|
subjectUserId: row.userId,
|
||||||
});
|
})),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAuthorizationStoreError(error)) {
|
if (isAuthorizationStoreError(error)) {
|
||||||
@@ -269,10 +506,21 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic keyset pagination over `created_at DESC, id DESC`. The boundary is a strict
|
||||||
|
* row-value comparison on *both* columns, so grants sharing a `created_at` — which concurrent
|
||||||
|
* creation makes routine — can neither be skipped nor repeated across pages. Offset pagination
|
||||||
|
* would do exactly that under concurrent writes, which is why it is not used.
|
||||||
|
*
|
||||||
|
* At most `limit + 1` rows are read: the extra row only answers "is there another page" and is
|
||||||
|
* never returned.
|
||||||
|
*/
|
||||||
listGrantsForUser: async (
|
listGrantsForUser: async (
|
||||||
userId: EkklesiaUserId,
|
userId: EkklesiaUserId,
|
||||||
options: ListGrantsForUserOptions = {},
|
listOptions: ListGrantsForUserOptions,
|
||||||
): Promise<readonly GrantRecord[]> => {
|
): Promise<GrantPage> => {
|
||||||
|
const limit = assertValidLimit(listOptions.limit);
|
||||||
|
const after = listOptions.after;
|
||||||
try {
|
try {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -280,14 +528,31 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(ekklesiaCoreGrant.userId, userId),
|
eq(ekklesiaCoreGrant.userId, userId),
|
||||||
...(options.sourceKind === undefined
|
...(listOptions.sourceKind === undefined
|
||||||
? []
|
? []
|
||||||
: [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]),
|
: [eq(ekklesiaCoreGrant.sourceKind, listOptions.sourceKind)]),
|
||||||
|
...(after === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
sql`(${ekklesiaCoreGrant.createdAt}, ${ekklesiaCoreGrant.id}) < (${after.createdAt}::timestamptz, ${after.id}::uuid)`,
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(ekklesiaCoreGrant.createdAt));
|
.orderBy(desc(ekklesiaCoreGrant.createdAt), desc(ekklesiaCoreGrant.id))
|
||||||
return Object.freeze(rows.map(toGrantRecord));
|
.limit(limit + 1);
|
||||||
} catch {
|
|
||||||
|
const page = rows.slice(0, limit).map(toGrantRecord);
|
||||||
|
const last = page.at(-1);
|
||||||
|
return Object.freeze({
|
||||||
|
grants: Object.freeze(page),
|
||||||
|
...(rows.length > limit && last !== undefined
|
||||||
|
? { nextBoundary: Object.freeze({ createdAt: last.createdAt, id: last.id }) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthorizationStoreError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { formatMigrationApplyReport } from "./migration-report.js";
|
import { formatMigrationApplyReport } from "./migration-report.js";
|
||||||
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
||||||
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js";
|
import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const aggregate = await discoverRepositoryMigrations();
|
const aggregate = await discoverRepositoryMigrations();
|
||||||
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL);
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
// Resolved and asserted before any client exists: an unsafe production transport fails
|
||||||
|
// here rather than after opening a connection.
|
||||||
|
const transport =
|
||||||
|
typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined;
|
||||||
|
const database = createPostgresMigrationDatabase(databaseUrl, {
|
||||||
|
...(transport === undefined ? {} : { transport }),
|
||||||
|
});
|
||||||
const result = await applyModuleMigrations({ aggregate, database });
|
const result = await applyModuleMigrations({ aggregate, database });
|
||||||
|
|
||||||
for (const line of formatMigrationApplyReport(result)) {
|
for (const line of formatMigrationApplyReport(result)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createAuthorizationDrizzleDatabase,
|
createAuthorizationDrizzleDatabase,
|
||||||
PlatformOwnerBootstrapError,
|
PlatformOwnerBootstrapError,
|
||||||
} from "../authorization/index.js";
|
} from "../authorization/index.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
|
|
||||||
class BootstrapCliError extends Error {
|
class BootstrapCliError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -67,9 +68,13 @@ async function main(): Promise<void> {
|
|||||||
throw new BootstrapCliError("DATABASE_URL is required.");
|
throw new BootstrapCliError("DATABASE_URL is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asserted before the connection is constructed, so an unsafe production transport never opens
|
||||||
|
// a client at all.
|
||||||
|
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||||
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
|
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
|
||||||
connectionTimeoutMs: 10_000,
|
connectionTimeoutMs: 10_000,
|
||||||
operationTimeoutMs: 10_000,
|
operationTimeoutMs: 10_000,
|
||||||
|
transport,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
|
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
createAuthorizationDrizzleDatabase,
|
||||||
|
createInitialPlatformOwner,
|
||||||
|
InitialPlatformOwnerError,
|
||||||
|
} from "../authorization/index.js";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
} from "../identity/index.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the first platform owner of a fresh installation — one identity, then the single-use
|
||||||
|
* ownership grant — so a freshly deployed Ekklesia has a supported way to get its first
|
||||||
|
* administrator (ADR 0035).
|
||||||
|
*
|
||||||
|
* This exists because the login page has nothing to log in to on a new installation. Public signup
|
||||||
|
* is disabled in every environment and production cannot enable it at all, which is correct and is
|
||||||
|
* not changed here; the consequence was simply that no supported path created the first account.
|
||||||
|
* This is that path, and it is deliberately the same shape as `pnpm authorization:bootstrap-owner`
|
||||||
|
* and `pnpm modules:enable`: explicit, operator-invoked, never HTTP-exposed, never inferred, and
|
||||||
|
* reachable only by someone who already holds the database credentials.
|
||||||
|
*
|
||||||
|
* It creates no HTTP route, starts no server, and applies no migration.
|
||||||
|
*/
|
||||||
|
class InitialOwnerCliError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "InitialOwnerCliError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage =
|
||||||
|
"Usage: pnpm identity:create-initial-owner -- --email <address> [--name <display name>]\n" +
|
||||||
|
"The password is never taken from the command line. It is prompted for when a terminal is\n" +
|
||||||
|
"attached, and otherwise read from standard input:\n" +
|
||||||
|
" printf '%s' \"$PASSWORD\" | pnpm identity:create-initial-owner -- --email [email protected]";
|
||||||
|
|
||||||
|
interface ParsedArgs {
|
||||||
|
readonly email: string;
|
||||||
|
readonly name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts` and
|
||||||
|
* `set-module-enabled.ts`. There is deliberately no `--password`: see `readPassword`. */
|
||||||
|
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||||
|
let email: string | undefined;
|
||||||
|
let name: string | undefined;
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const flag = argv[index];
|
||||||
|
if (flag === "--email") {
|
||||||
|
email = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (flag === "--name") {
|
||||||
|
name = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (flag === "--password" || flag?.startsWith("--password=") === true) {
|
||||||
|
// Refused explicitly rather than ignored, so an operator who tries it learns why instead of
|
||||||
|
// wondering why their password was not used.
|
||||||
|
throw new InitialOwnerCliError(
|
||||||
|
"--password is not supported: a command-line password is visible in shell history and in the process list.\n" +
|
||||||
|
usage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email === undefined || email.trim().length === 0) {
|
||||||
|
throw new InitialOwnerCliError(usage);
|
||||||
|
}
|
||||||
|
return name === undefined ? { email } : { email, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads one line without echoing it. No dependency is added for this: the command needs exactly
|
||||||
|
* one hidden field, and Node's own raw-mode stdin is enough. */
|
||||||
|
function promptHidden(prompt: string): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const input = process.stdin;
|
||||||
|
process.stdout.write(prompt);
|
||||||
|
input.setRawMode(true);
|
||||||
|
input.resume();
|
||||||
|
input.setEncoding("utf8");
|
||||||
|
let value = "";
|
||||||
|
const finish = (settle: () => void): void => {
|
||||||
|
input.removeListener("data", onData);
|
||||||
|
input.setRawMode(false);
|
||||||
|
input.pause();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
settle();
|
||||||
|
};
|
||||||
|
const onData = (chunk: string): void => {
|
||||||
|
for (const character of chunk) {
|
||||||
|
if (character === "\r" || character === "\n" || character === "\u0004") {
|
||||||
|
finish(() => resolve(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (character === "\u0003") {
|
||||||
|
finish(() => reject(new InitialOwnerCliError("Cancelled. No changes were made.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Backspace and delete, so a mistyped character can be corrected in a field nobody can see.
|
||||||
|
if (character === "\u007f" || character === "\b") {
|
||||||
|
value = value.slice(0, -1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value += character;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.on("data", onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads standard input to end, for automated deployment. */
|
||||||
|
function readStdin(): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (chunk: string) => {
|
||||||
|
buffer += chunk;
|
||||||
|
});
|
||||||
|
process.stdin.on("end", () => resolve(buffer));
|
||||||
|
process.stdin.on("error", () =>
|
||||||
|
reject(new InitialOwnerCliError("Standard input could not be read.")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains the password without it ever appearing in `argv`, an environment variable, or the
|
||||||
|
* terminal.
|
||||||
|
*
|
||||||
|
* A command-line password is visible in shell history and to every other process on the machine via
|
||||||
|
* the process list, and an environment variable tends to become permanent — a bootstrap password
|
||||||
|
* belongs to one invocation, not to the deployment's configuration, so this command deliberately
|
||||||
|
* reads no environment variable for it and none is added to `.env.example`.
|
||||||
|
*
|
||||||
|
* Interactive use asks twice and compares, because the field is invisible and a typo would create
|
||||||
|
* an owner nobody can sign in as, on an installation whose one bootstrap has now been consumed.
|
||||||
|
*/
|
||||||
|
async function readPassword(): Promise<string> {
|
||||||
|
if (process.stdin.isTTY === true) {
|
||||||
|
const password = await promptHidden("Password: ");
|
||||||
|
const confirmation = await promptHidden("Confirm password: ");
|
||||||
|
if (password !== confirmation) {
|
||||||
|
throw new InitialOwnerCliError("The passwords did not match. No changes were made.");
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
// A single trailing newline is stripped so `printf '%s\n'`, a here-string, and an editor-produced
|
||||||
|
// file all behave the same; nothing else is trimmed, because leading and trailing spaces are
|
||||||
|
// legitimate password characters.
|
||||||
|
const piped = await readStdin();
|
||||||
|
return piped.replace(/\r?\n$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const { email, name } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
// Read only when this command actually executes — never merely by importing this module.
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||||
|
throw new InitialOwnerCliError("DATABASE_URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = await readPassword();
|
||||||
|
if (password.length === 0) {
|
||||||
|
throw new InitialOwnerCliError("A password is required. No changes were made.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asserted before any connection is constructed, so an unsafe production transport never opens a
|
||||||
|
// client at all — identical to every other Core executable.
|
||||||
|
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||||
|
const databaseOptions = {
|
||||||
|
connectionTimeoutMs: 10_000,
|
||||||
|
operationTimeoutMs: 30_000,
|
||||||
|
transport,
|
||||||
|
};
|
||||||
|
const authorization = createAuthorizationDrizzleDatabase(databaseUrl, databaseOptions);
|
||||||
|
const identity = createIdentityDrizzleDatabase(databaseUrl, databaseOptions);
|
||||||
|
try {
|
||||||
|
const result = await createInitialPlatformOwner(
|
||||||
|
{
|
||||||
|
authorizationDb: authorization.db,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
identityDb: identity.db,
|
||||||
|
},
|
||||||
|
{ email, name: name ?? email, password },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Never the password, its length, a hash, a session token, a user id, or the database URL.
|
||||||
|
console.log(
|
||||||
|
result.identity === "created"
|
||||||
|
? "Initial platform owner created successfully."
|
||||||
|
: "An account already existed for that address; platform ownership was granted to it and its password was left unchanged.",
|
||||||
|
);
|
||||||
|
console.log(`Email: ${result.email}`);
|
||||||
|
console.log("You can now sign in to the Ekklesia dashboard.");
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
authorization.close().catch(() => undefined),
|
||||||
|
identity.close().catch(() => undefined),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportInitialOwnerCliError(error: unknown): void {
|
||||||
|
if (error instanceof InitialOwnerCliError) {
|
||||||
|
console.error(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof InitialPlatformOwnerError) {
|
||||||
|
console.error(`[${error.code}] ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deliberately generic: the caught value may carry SQL, a connection string, a driver detail, or
|
||||||
|
// the password itself if it were ever interpolated into a message upstream.
|
||||||
|
console.error("Initial platform owner creation failed unexpectedly.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectExecution(): boolean {
|
||||||
|
const entrypoint = process.argv[1];
|
||||||
|
return (
|
||||||
|
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectExecution()) {
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
reportInitialOwnerCliError(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
assertProductionDatabaseTransport,
|
||||||
|
type DatabaseTransportEnvironment,
|
||||||
|
type DatabaseTransportPolicy,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
|
} from "../database/transport.js";
|
||||||
|
import { loadHostConfiguration } from "../host/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The database transport policy for the small Core-owned executables — `pnpm migrations:plan`,
|
||||||
|
* `pnpm migrations:apply`, `pnpm modules:enable`, and the owner-bootstrap commands — resolved from
|
||||||
|
* the process environment.
|
||||||
|
*
|
||||||
|
* The mapping itself belongs to `loadHostConfiguration`, which `startApi` and `pnpm doctor` also
|
||||||
|
* call, so `NODE_ENV` and the two TLS variables have exactly one interpretation across every entry
|
||||||
|
* point. Environment *reading* still happens here, in the executable layer: the reusable Core
|
||||||
|
* database and migration constructors keep taking explicit typed inputs and never touch
|
||||||
|
* `process.env`.
|
||||||
|
*/
|
||||||
|
export function readDatabaseTransportFromEnvironment(): {
|
||||||
|
readonly environment: DatabaseTransportEnvironment;
|
||||||
|
readonly transport: DatabaseTransportPolicy;
|
||||||
|
} {
|
||||||
|
const host = loadHostConfiguration(process.env);
|
||||||
|
const transport = createDatabaseTransportPolicy(host.databaseTransport, {
|
||||||
|
environment: host.environment,
|
||||||
|
});
|
||||||
|
return { environment: host.environment, transport };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the policy and asserts it is safe for the given database URL before any client, pool,
|
||||||
|
* or connection is constructed. Throws the sanitized `DatabaseTransportError` otherwise.
|
||||||
|
*/
|
||||||
|
export function resolveExecutableDatabaseTransport(databaseUrl: string): DatabaseTransportPolicy {
|
||||||
|
const { environment, transport } = readDatabaseTransportFromEnvironment();
|
||||||
|
assertProductionDatabaseTransport(databaseUrl, transport, environment);
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
+51
-22
@@ -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,22 +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 trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
* Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
|
||||||
const trustedOrigins = trustedOriginsValue
|
* with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
|
||||||
? trustedOriginsValue
|
* deployment is configured to use. Doctor previously read its own subset here and silently omitted
|
||||||
.split(",")
|
* object storage and module secrets entirely, which made it report a correctly configured
|
||||||
.map((origin) => origin.trim())
|
* installation as having neither.
|
||||||
.filter((origin) => origin.length > 0)
|
*
|
||||||
: undefined;
|
* Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
|
||||||
|
* malformed value appears in the report — which is the whole reason an operator runs this command —
|
||||||
|
* instead of throwing before a report exists.
|
||||||
|
*/
|
||||||
|
export function createDoctorOptions(
|
||||||
|
environment: HostEnvironment,
|
||||||
|
metadata: DoctorCommandMetadata,
|
||||||
|
): DoctorOptions {
|
||||||
|
const host = loadHostConfiguration(environment);
|
||||||
return {
|
return {
|
||||||
...(baseUrl ? { baseUrl } : {}),
|
clientAddress: host.clientAddress,
|
||||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
databaseEnvironment: host.environment,
|
||||||
...(secret ? { secret } : {}),
|
databaseTransport: host.databaseTransport,
|
||||||
...(trustedOrigins ? { trustedOrigins } : {}),
|
databaseUrl: host.databaseUrl,
|
||||||
|
identityConfiguration: { ...host.identity, environment: host.environment },
|
||||||
|
mailConfiguration: host.mail,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: metadata.moduleSdkVersion,
|
||||||
|
modulesDirectory: metadata.modulesDirectory,
|
||||||
|
platformVersion: metadata.platformVersion,
|
||||||
|
},
|
||||||
|
moduleSecrets: host.moduleSecrets,
|
||||||
|
runtimeRange: metadata.runtimeRange,
|
||||||
|
runtimeVersion: metadata.runtimeVersion,
|
||||||
|
storageConfiguration: host.storage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,17 +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 report = await diagnoseEkklesia({
|
const report = await diagnoseEkklesia(
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
createDoctorOptions(process.env, {
|
||||||
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);
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import { DatabaseTransportError } from "../database/transport.js";
|
||||||
import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js";
|
import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js";
|
||||||
import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js";
|
import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js";
|
||||||
|
|
||||||
export function reportMigrationCommandError(error: unknown): void {
|
export function reportMigrationCommandError(error: unknown): void {
|
||||||
|
if (error instanceof DatabaseTransportError) {
|
||||||
|
// The message is a fixed string chosen by code; it never carries the connection string, host,
|
||||||
|
// port, credentials, CA material, or the offending mode value, so it is safe to print as-is —
|
||||||
|
// and an operator who misconfigured TLS needs to know that, not "failed unexpectedly".
|
||||||
|
console.error(`[${error.code}] ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (error instanceof ModuleDiscoveryError) {
|
if (error instanceof ModuleDiscoveryError) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
for (const issue of error.issues) {
|
for (const issue of error.issues) {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { formatMigrationPlanReport } from "./migration-report.js";
|
import { formatMigrationPlanReport } from "./migration-report.js";
|
||||||
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
import { discoverRepositoryMigrations } from "./migration-command-support.js";
|
||||||
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
import { reportMigrationCommandError } from "./migration-command-errors.js";
|
||||||
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js";
|
import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const aggregate = await discoverRepositoryMigrations();
|
const aggregate = await discoverRepositoryMigrations();
|
||||||
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL);
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
// Resolved and asserted before any client exists: an unsafe production transport fails
|
||||||
|
// here rather than after opening a connection.
|
||||||
|
const transport =
|
||||||
|
typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined;
|
||||||
|
const database = createPostgresMigrationDatabase(databaseUrl, {
|
||||||
|
...(transport === undefined ? {} : { transport }),
|
||||||
|
});
|
||||||
const plan = await planModuleMigrations({ aggregate, database });
|
const plan = await planModuleMigrations({ aggregate, database });
|
||||||
|
|
||||||
for (const line of formatMigrationPlanReport(plan)) {
|
for (const line of formatMigrationPlanReport(plan)) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./pool.js";
|
export * from "./pool.js";
|
||||||
|
export * from "./transport.js";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolConfig } from "pg";
|
import { Pool, type PoolConfig } from "pg";
|
||||||
|
import { type DatabaseTransportPolicy, databaseTlsConnectionOptions } from "./transport.js";
|
||||||
|
|
||||||
export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID";
|
export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID";
|
||||||
|
|
||||||
@@ -22,6 +23,11 @@ export interface PostgresPoolOptions {
|
|||||||
/** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds
|
/** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds
|
||||||
* establishing a connection, not a hung query already running on one. */
|
* establishing a connection, not a hung query already running on one. */
|
||||||
readonly operationTimeoutMs?: number;
|
readonly operationTimeoutMs?: number;
|
||||||
|
/** The validated transport policy (`./transport.js`). Omitted only by isolated unit-test and
|
||||||
|
* single-domain factories that connect to a disposable loopback database; every production
|
||||||
|
* executable passes one explicitly. When omitted, no `ssl` option is set and the connection is
|
||||||
|
* plaintext — exactly the pre-existing development behavior. */
|
||||||
|
readonly transport?: DatabaseTransportPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostgresPoolHandle {
|
export interface PostgresPoolHandle {
|
||||||
@@ -84,8 +90,16 @@ export function createValidatedPostgresPool(
|
|||||||
const validatedConnectionString = validateConnectionString(connectionString);
|
const validatedConnectionString = validateConnectionString(connectionString);
|
||||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||||
|
// `databaseTlsConnectionOptions` also rejects a URL carrying its own TLS parameters, so the
|
||||||
|
// pinned driver's `Object.assign({}, config, parse(connectionString))` precedence can never
|
||||||
|
// silently replace the policy's `ssl` object with a weaker one.
|
||||||
|
const ssl =
|
||||||
|
options.transport === undefined
|
||||||
|
? undefined
|
||||||
|
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
|
||||||
const poolConfig: PoolConfig = {
|
const poolConfig: PoolConfig = {
|
||||||
connectionString: validatedConnectionString,
|
connectionString: validatedConnectionString,
|
||||||
|
...(ssl === undefined ? {} : { ssl }),
|
||||||
...(validatedConnectionTimeoutMs === undefined
|
...(validatedConnectionTimeoutMs === undefined
|
||||||
? {}
|
? {}
|
||||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import net from "node:net";
|
||||||
|
import tls from "node:tls";
|
||||||
|
import type { ConnectionOptions } from "node:tls";
|
||||||
|
|
||||||
|
export type DatabaseTransportEnvironment = "development" | "production";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two transport modes this release supports, named after the libpq/PostgreSQL vocabulary an
|
||||||
|
* operator already knows:
|
||||||
|
*
|
||||||
|
* - `verify-full` — TLS is required, the server certificate must chain to a trusted CA, **and**
|
||||||
|
* the certificate identity must match the configured database host. This is the production
|
||||||
|
* default, and the only mode a production deployment may use.
|
||||||
|
* - `disable` — no TLS at all. Available only for the disposable loopback PostgreSQL used by local
|
||||||
|
* development and this repository's integration harness.
|
||||||
|
*
|
||||||
|
* There is deliberately no encryption-only mode (`require`/`verify-ca`) and no
|
||||||
|
* `rejectUnauthorized: false` escape hatch: both authenticate nothing, and an unauthenticated
|
||||||
|
* encrypted channel to an attacker-controlled endpoint is not a security property worth offering.
|
||||||
|
*/
|
||||||
|
export type DatabaseTlsMode = "disable" | "verify-full";
|
||||||
|
|
||||||
|
export type DatabaseTransportErrorCode =
|
||||||
|
| "DATABASE_TLS_CONNECTION_STRING_CONFLICT"
|
||||||
|
| "DATABASE_TLS_MODE_INVALID"
|
||||||
|
| "DATABASE_TLS_REQUIRED_IN_PRODUCTION"
|
||||||
|
| "DATABASE_URL_INVALID";
|
||||||
|
|
||||||
|
const errorMessages: Readonly<Record<DatabaseTransportErrorCode, string>> = Object.freeze({
|
||||||
|
DATABASE_TLS_CONNECTION_STRING_CONFLICT:
|
||||||
|
"The database URL must not carry TLS parameters; configure the database TLS mode explicitly.",
|
||||||
|
DATABASE_TLS_MODE_INVALID: 'The database TLS mode must be exactly "verify-full" or "disable".',
|
||||||
|
DATABASE_TLS_REQUIRED_IN_PRODUCTION:
|
||||||
|
"A production database connection must use certificate-verified TLS.",
|
||||||
|
DATABASE_URL_INVALID: "The database URL must be an absolute postgres:// or postgresql:// URL.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Never carries the connection string, host, port, database name, credentials, certificate, CA
|
||||||
|
* material, or the offending mode value. */
|
||||||
|
export class DatabaseTransportError extends Error {
|
||||||
|
readonly code: DatabaseTransportErrorCode;
|
||||||
|
|
||||||
|
constructor(code: DatabaseTransportErrorCode) {
|
||||||
|
super(errorMessages[code]);
|
||||||
|
this.name = "DatabaseTransportError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatabaseTransportPolicy {
|
||||||
|
readonly mode: DatabaseTlsMode;
|
||||||
|
/** PEM trust material for a private PostgreSQL CA. Optional: when omitted, Node's own trusted CA
|
||||||
|
* set is used. Never logged, never echoed in an error. */
|
||||||
|
readonly certificateAuthority?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatabaseTransportPolicyInput {
|
||||||
|
readonly mode?: unknown;
|
||||||
|
readonly certificateAuthority?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDatabaseTransportPolicyOptions {
|
||||||
|
/** Defaults to production, so an omitted environment never silently relaxes transport security. */
|
||||||
|
readonly environment?: DatabaseTransportEnvironment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every TLS-related parameter `[email protected]` recognizes in a PostgreSQL URL. These
|
||||||
|
* are rejected outright rather than merged, because the pinned driver resolves them in a way that
|
||||||
|
* silently defeats an explicit policy: `pg`'s own `ConnectionParameters` does
|
||||||
|
* `Object.assign({}, config, parse(config.connectionString))`
|
||||||
|
* (`pg/lib/connection-parameters.js`), so a URL parameter **overwrites** the `ssl` object a caller
|
||||||
|
* passed alongside it. Verified directly against a live TLS PostgreSQL instance: a
|
||||||
|
* `?sslmode=disable` URL combined with an explicit certificate-verifying `ssl` object connects in
|
||||||
|
* plaintext.
|
||||||
|
*
|
||||||
|
* The pinned release additionally aliases `prefer`/`require`/`verify-ca` to full verification while
|
||||||
|
* printing a deprecation warning that they will adopt weaker libpq semantics in `pg@9`, so even
|
||||||
|
* a URL that looks safe today is not a durable guarantee. One typed policy is the whole contract.
|
||||||
|
*/
|
||||||
|
const CONNECTION_STRING_TLS_PARAMETERS = Object.freeze([
|
||||||
|
"ssl",
|
||||||
|
"sslcert",
|
||||||
|
"sslkey",
|
||||||
|
"sslmode",
|
||||||
|
"sslnegotiation",
|
||||||
|
"sslpassword",
|
||||||
|
"sslrootcert",
|
||||||
|
"uselibpqcompat",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseDatabaseUrl(databaseUrl: string): URL {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(databaseUrl);
|
||||||
|
} catch {
|
||||||
|
throw new DatabaseTransportError("DATABASE_URL_INVALID");
|
||||||
|
}
|
||||||
|
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
|
||||||
|
throw new DatabaseTransportError("DATABASE_URL_INVALID");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host a certificate's identity must match — the exact host the driver will connect to. */
|
||||||
|
export function databaseHostFromUrl(databaseUrl: string): string {
|
||||||
|
const url = parseDatabaseUrl(databaseUrl);
|
||||||
|
// `URL.hostname` keeps IPv6 literals bracketed; TLS identity checking wants the bare address.
|
||||||
|
const hostname = url.hostname;
|
||||||
|
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the database endpoint is an unambiguous loopback literal. Used only to decide whether
|
||||||
|
* the development plaintext mode is even permissible; it is never a production allowance. */
|
||||||
|
function isLoopbackDatabaseHost(host: string): boolean {
|
||||||
|
const normalized = host.toLowerCase();
|
||||||
|
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (net.isIPv4(normalized)) {
|
||||||
|
return normalized.startsWith("127.");
|
||||||
|
}
|
||||||
|
if (net.isIPv6(normalized)) {
|
||||||
|
return normalized === "::1" || normalized === "::ffff:127.0.0.1";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the typed transport policy. Parsing is deliberately separate from environment policy,
|
||||||
|
* matching the convention the public-signup gate already established: a malformed mode is reported
|
||||||
|
* as malformed in every environment, never relabeled as a production failure.
|
||||||
|
*/
|
||||||
|
export function createDatabaseTransportPolicy(
|
||||||
|
input: DatabaseTransportPolicyInput = {},
|
||||||
|
options: CreateDatabaseTransportPolicyOptions = {},
|
||||||
|
): DatabaseTransportPolicy {
|
||||||
|
const environment = options.environment ?? "production";
|
||||||
|
|
||||||
|
let mode: DatabaseTlsMode;
|
||||||
|
if (input.mode === undefined) {
|
||||||
|
// Production fails safe to verified TLS; development keeps the plaintext disposable-PostgreSQL
|
||||||
|
// workflow this repository's harness relies on.
|
||||||
|
mode = environment === "production" ? "verify-full" : "disable";
|
||||||
|
} else if (input.mode === "verify-full" || input.mode === "disable") {
|
||||||
|
mode = input.mode;
|
||||||
|
} else {
|
||||||
|
// No coercion of booleans, `1`, `"TRUE"`, `" verify-full "`, or any other loose spelling.
|
||||||
|
throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (environment === "production" && mode !== "verify-full") {
|
||||||
|
throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.certificateAuthority !== undefined &&
|
||||||
|
(typeof input.certificateAuthority !== "string" ||
|
||||||
|
input.certificateAuthority.trim().length === 0)
|
||||||
|
) {
|
||||||
|
throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
...(typeof input.certificateAuthority === "string"
|
||||||
|
? { certificateAuthority: input.certificateAuthority }
|
||||||
|
: {}),
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects any database URL carrying its own TLS parameters. Called before a pool or client is ever
|
||||||
|
* constructed, so a URL can never silently downgrade or contradict the validated policy — there is
|
||||||
|
* exactly one place transport security is decided.
|
||||||
|
*/
|
||||||
|
export function assertNoConnectionStringTlsParameters(databaseUrl: string): void {
|
||||||
|
const url = parseDatabaseUrl(databaseUrl);
|
||||||
|
for (const [name] of url.searchParams) {
|
||||||
|
if (CONNECTION_STRING_TLS_PARAMETERS.includes(name.toLowerCase())) {
|
||||||
|
throw new DatabaseTransportError("DATABASE_TLS_CONNECTION_STRING_CONFLICT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates the validated policy into the exact pinned-`pg` options that achieve it, and asserts
|
||||||
|
* the URL cannot override them.
|
||||||
|
*
|
||||||
|
* `verify-full` is **not** simply `ssl: true` or `{ rejectUnauthorized: true }`. Verified directly
|
||||||
|
* against a live TLS PostgreSQL instance: those verify the certificate *chain*, but `pg` only sets
|
||||||
|
* `options.servername` for non-IP hosts (`pg/lib/connection.js`) and passes `tls.connect` a bare
|
||||||
|
* socket carrying no host, so for an IP endpoint Node has no identity to check and falls back to
|
||||||
|
* the fixed name `localhost` — never the address actually dialled. A certificate issued for an
|
||||||
|
* entirely different name is therefore accepted when connecting to an IP address. Supplying an
|
||||||
|
* explicit `checkServerIdentity` pinned to the configured host closes that gap for DNS and IP hosts
|
||||||
|
* alike (proven: a certificate valid only for `localhost` is rejected with
|
||||||
|
* `ERR_TLS_CERT_ALTNAME_INVALID` when reached via `127.0.0.1`).
|
||||||
|
*/
|
||||||
|
export function databaseTlsConnectionOptions(
|
||||||
|
databaseUrl: string,
|
||||||
|
policy: DatabaseTransportPolicy,
|
||||||
|
): false | ConnectionOptions {
|
||||||
|
assertNoConnectionStringTlsParameters(databaseUrl);
|
||||||
|
if (policy.mode === "disable") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const host = databaseHostFromUrl(databaseUrl);
|
||||||
|
return {
|
||||||
|
...(policy.certificateAuthority === undefined ? {} : { ca: policy.certificateAuthority }),
|
||||||
|
// Chain verification. There is no configuration that can set this to `false`.
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
// Identity verification against the host actually being connected to — never the SNI value pg
|
||||||
|
// may or may not have set, and never skipped for an IP endpoint.
|
||||||
|
checkServerIdentity: (_servername: string, certificate: tls.PeerCertificate) =>
|
||||||
|
tls.checkServerIdentity(host, certificate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one production guard every executable database path shares: a production deployment must use
|
||||||
|
* `verify-full`, and its URL must not carry TLS parameters. There is no production plaintext
|
||||||
|
* exception — not for loopback, not for an RFC1918 address, not for a Docker service name. A
|
||||||
|
* production process that genuinely runs beside its database still gets certificate-verified TLS,
|
||||||
|
* which costs nothing and removes an entire class of "it was only internal" misconfiguration.
|
||||||
|
*/
|
||||||
|
export function assertProductionDatabaseTransport(
|
||||||
|
databaseUrl: string,
|
||||||
|
policy: DatabaseTransportPolicy,
|
||||||
|
environment: DatabaseTransportEnvironment,
|
||||||
|
): void {
|
||||||
|
assertNoConnectionStringTlsParameters(databaseUrl);
|
||||||
|
if (environment !== "production") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (policy.mode !== "verify-full") {
|
||||||
|
throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fixed, generic description of the effective transport for diagnostics. Never includes the
|
||||||
|
* URL, host, port, database name, credentials, certificate, or CA path. */
|
||||||
|
export function describeDatabaseTransport(policy: DatabaseTransportPolicy): string {
|
||||||
|
return policy.mode === "verify-full"
|
||||||
|
? `TLS required with certificate and hostname verification${
|
||||||
|
policy.certificateAuthority === undefined ? "" : " (explicit private CA supplied)"
|
||||||
|
}.`
|
||||||
|
: "TLS disabled (development/test plaintext).";
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isLoopbackDatabaseHost };
|
||||||
@@ -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", ["postgresql.connection", "postgresql.plan"]),
|
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||||
section("Identity", ["identity.configuration"]),
|
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: "✓",
|
||||||
|
|||||||
@@ -1,18 +1,46 @@
|
|||||||
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 {
|
||||||
|
assertProductionDatabaseTransport,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
|
type DatabaseTransportEnvironment,
|
||||||
|
DatabaseTransportError,
|
||||||
|
type DatabaseTransportPolicyInput,
|
||||||
|
describeDatabaseTransport,
|
||||||
|
} from "../database/transport.js";
|
||||||
|
import {
|
||||||
|
ClientAddressConfigError,
|
||||||
|
type ClientAddressConfigInput,
|
||||||
|
createClientAddressConfig,
|
||||||
|
} from "../identity/client-address.js";
|
||||||
import {
|
import {
|
||||||
type CreateIdentityConfigOptions,
|
type CreateIdentityConfigOptions,
|
||||||
createIdentityConfig,
|
createIdentityConfig,
|
||||||
IdentityConfigError,
|
IdentityConfigError,
|
||||||
} from "../identity/config.js";
|
} from "../identity/config.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,
|
||||||
@@ -25,26 +53,52 @@ 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"
|
||||||
| "platform.runtime"
|
| "platform.runtime"
|
||||||
| "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"
|
||||||
| "object-storage.integration";
|
| "identity.client-address"
|
||||||
|
| "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";
|
||||||
@@ -63,15 +117,46 @@ 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 secret?: string | undefined;
|
readonly secret?: string | undefined;
|
||||||
readonly trustedOrigins?: readonly string[] | undefined;
|
readonly trustedOrigins?: readonly string[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DoctorOptions {
|
export interface DoctorOptions {
|
||||||
|
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||||
|
/** The environment the executable resolved, used only for transport policy evaluation; identity
|
||||||
|
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
|
||||||
|
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
|
||||||
|
readonly databaseTransport?: DatabaseTransportPolicyInput | undefined;
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -91,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;
|
||||||
@@ -112,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,
|
||||||
@@ -119,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,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;
|
||||||
@@ -165,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)`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,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"
|
||||||
@@ -363,6 +769,7 @@ function identityConfigurationCheck(
|
|||||||
const hasAnyValue =
|
const hasAnyValue =
|
||||||
input?.secret !== undefined ||
|
input?.secret !== undefined ||
|
||||||
input?.baseUrl !== undefined ||
|
input?.baseUrl !== undefined ||
|
||||||
|
input?.publicSignupEnabled !== undefined ||
|
||||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||||
|
|
||||||
if (!hasAnyValue) {
|
if (!hasAnyValue) {
|
||||||
@@ -373,9 +780,10 @@ function identityConfigurationCheck(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
createIdentityConfig(
|
const config = createIdentityConfig(
|
||||||
{
|
{
|
||||||
baseUrl: input?.baseUrl,
|
baseUrl: input?.baseUrl,
|
||||||
|
publicSignupEnabled: input?.publicSignupEnabled,
|
||||||
secret: input?.secret,
|
secret: input?.secret,
|
||||||
trustedOrigins: input?.trustedOrigins,
|
trustedOrigins: input?.trustedOrigins,
|
||||||
},
|
},
|
||||||
@@ -383,6 +791,9 @@ function identityConfigurationCheck(
|
|||||||
);
|
);
|
||||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||||
schemaDetail,
|
schemaDetail,
|
||||||
|
config.publicSignupEnabled
|
||||||
|
? "Public signup is explicitly ENABLED."
|
||||||
|
: "Public signup is disabled (safe default).",
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
@@ -394,6 +805,76 @@ function identityConfigurationCheck(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure configuration validation only, mirroring `identityConfigurationCheck`: never resolves a real
|
||||||
|
* peer address or reads a real request. Omitted configuration is a `pass`, not a skip — direct mode
|
||||||
|
* (trusting only the actual immediate socket peer) is itself a complete, safe default, unlike
|
||||||
|
* identity configuration, which has no meaningful default at all. An explicitly configured
|
||||||
|
* trusted-proxy mode reports its header name and trusted-proxy count; an invalid or contradictory
|
||||||
|
* combination is a `failure`, distinguishing the three states this pass's audit specifically calls
|
||||||
|
* for: safe direct mode, explicitly configured proxy mode, and an invalid configuration.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Pure configuration validation only: never opens a connection, and never prints the database URL,
|
||||||
|
* host, port, database name, credentials, certificate, or CA material. An unsafe production
|
||||||
|
* transport is a blocking `failure`, not a warning — a production deployment that would reach
|
||||||
|
* PostgreSQL without certificate-verified TLS is not "ready with warnings".
|
||||||
|
*/
|
||||||
|
function databaseTransportCheck(
|
||||||
|
input: DatabaseTransportPolicyInput | undefined,
|
||||||
|
databaseUrl: string | undefined,
|
||||||
|
environment: DatabaseTransportEnvironment | undefined,
|
||||||
|
): DoctorCheck {
|
||||||
|
const effectiveEnvironment = environment ?? "production";
|
||||||
|
try {
|
||||||
|
const policy = createDatabaseTransportPolicy(input, { environment: effectiveEnvironment });
|
||||||
|
if (typeof databaseUrl === "string" && databaseUrl.trim() !== "") {
|
||||||
|
// Also rejects a URL carrying TLS parameters that could override the policy.
|
||||||
|
assertProductionDatabaseTransport(databaseUrl, policy, effectiveEnvironment);
|
||||||
|
}
|
||||||
|
return freezeCheck(
|
||||||
|
"database.transport",
|
||||||
|
"pass",
|
||||||
|
policy.mode === "verify-full"
|
||||||
|
? "Database TLS verified"
|
||||||
|
: "Database TLS disabled (development)",
|
||||||
|
[describeDatabaseTransport(policy)],
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error instanceof DatabaseTransportError ? error.code : "DATABASE_TRANSPORT_INVALID";
|
||||||
|
return freezeCheck("database.transport", "failure", "Database transport unsafe", [
|
||||||
|
`[${code}] The configured database transport is not permitted.`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||||
|
try {
|
||||||
|
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||||
|
if (config.mode === "direct") {
|
||||||
|
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
||||||
|
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
||||||
|
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
||||||
|
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error instanceof ClientAddressConfigError
|
||||||
|
? error.code
|
||||||
|
: "CLIENT_ADDRESS_CONFIGURATION_INVALID";
|
||||||
|
return freezeCheck(
|
||||||
|
"identity.client-address",
|
||||||
|
"failure",
|
||||||
|
"Client-address configuration invalid",
|
||||||
|
[`[${code}] Configured client-address values failed validation.`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
|
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
|
||||||
return [
|
return [
|
||||||
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
|
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
|
||||||
@@ -411,6 +892,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)
|
||||||
@@ -477,6 +975,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;
|
||||||
@@ -531,9 +1097,16 @@ export async function diagnoseEkklesia(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
// Doctor's own read-only connection uses the exact same validated transport policy every
|
||||||
|
// other executable does, so a Doctor "connection established" can never mean something
|
||||||
|
// weaker than what `pnpm api:start` or `pnpm migrations:apply` would use.
|
||||||
|
const doctorTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
||||||
|
environment: options.databaseEnvironment ?? "production",
|
||||||
|
});
|
||||||
const database = dependencies.createMigrationDatabase(databaseUrl, {
|
const database = dependencies.createMigrationDatabase(databaseUrl, {
|
||||||
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
operationTimeoutMs: doctorPostgresTimeoutMs,
|
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
|
transport: doctorTransport,
|
||||||
});
|
});
|
||||||
const plan = await dependencies.planMigrations({ aggregate: migrations, database });
|
const plan = await dependencies.planMigrations({ aggregate: migrations, database });
|
||||||
postgresConnectionCheck = freezeCheck(
|
postgresConnectionCheck = freezeCheck(
|
||||||
@@ -548,21 +1121,154 @@ export async function diagnoseEkklesia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
||||||
|
const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress);
|
||||||
|
const transportCheck = databaseTransportCheck(
|
||||||
|
options.databaseTransport,
|
||||||
|
options.databaseUrl,
|
||||||
|
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,
|
||||||
postgresConnectionCheck,
|
postgresConnectionCheck,
|
||||||
postgresPlanCheck,
|
postgresPlanCheck,
|
||||||
identityCheck,
|
identityCheck,
|
||||||
|
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";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user