Compare commits
22
Commits
3581ad7c8e
..
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 |
@@ -81,3 +81,30 @@ S3_BUCKET=ekklesia-development
|
||||
# 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.
|
||||
|
||||
@@ -15,7 +15,7 @@ monorepo:
|
||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||
surface directly. Per ADR 0021 a module owns its UI; manifest v6 permits independently optional
|
||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||
|
||||
+22
-7
@@ -35,13 +35,21 @@ pnpm infrastructure:config
|
||||
private source paths.
|
||||
- Use `workspace:` ranges for internal package dependencies.
|
||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
||||
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||
other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
||||
bundler configuration, and styling is React inline style objects only for now. Both roles use the
|
||||
same UI SDK context with no presentation-mode flag.
|
||||
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
|
||||
@@ -51,10 +59,17 @@ pnpm infrastructure:config
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -52,7 +52,8 @@ order never grants elevation — but is now **disabled by default, and cannot be
|
||||
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
|
||||
[API](docs/development/api.md) and
|
||||
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
||||
Email verification, password recovery, and invitation/operator enrollment still do not exist.
|
||||
Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
|
||||
enrollment still do not.
|
||||
Public registration remains disabled by default and prohibited in production — and a fresh
|
||||
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,
|
||||
@@ -288,7 +289,8 @@ artifacts cannot drift.
|
||||
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: a stylesheet import **fails the build** rather than emitting a sidecar
|
||||
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
|
||||
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
|
||||
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||||
and installing a module or starting the API never triggers one.
|
||||
|
||||
@@ -487,7 +489,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
|
||||
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 6 after ADR 0033 added the optional display declaration.
|
||||
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
|
||||
@@ -719,12 +722,31 @@ ambiguous, and retrying it would send someone several copies of the same reset e
|
||||
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, since
|
||||
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without
|
||||
sending anything**, while stating plainly that a successful connection does not establish
|
||||
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's
|
||||
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
|
||||
the seam they will use. See [Outbound email](docs/development/email.md).
|
||||
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
|
||||
@@ -772,6 +794,74 @@ 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
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
@@ -869,7 +959,7 @@ Object storage
|
||||
|
||||
Outbound email
|
||||
○ Outbound email not configured
|
||||
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
|
||||
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
|
||||
```
|
||||
|
||||
@@ -28,11 +28,15 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/smtp-server": "3.5.13",
|
||||
"better-auth": "1.6.23",
|
||||
"happy-dom": "20.0.11",
|
||||
"pg": "8.22.0",
|
||||
"smtp-server": "3.19.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
|
||||
+187
-17
@@ -1,6 +1,10 @@
|
||||
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
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
|
||||
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
|
||||
* 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 route declaration is validated against at registration) is the hard
|
||||
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
|
||||
* nothing in this platform ever declares, or enforces, a larger single request body than this.
|
||||
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
|
||||
* a future dedicated storage design, not to widening this ceiling.
|
||||
* 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_MAX_BODY_BYTES_CEILING };
|
||||
export {
|
||||
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||
};
|
||||
|
||||
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
||||
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
||||
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
|
||||
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
||||
}
|
||||
|
||||
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
|
||||
* contract rather than redefining `onError` per route. Covers both a declared oversized
|
||||
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
|
||||
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
|
||||
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
|
||||
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
|
||||
export function platformBodyLimit(maxSize: number) {
|
||||
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,8 +79,10 @@ export interface ApiRuntimeConfig {
|
||||
* The validated host outbound-email provider, or `undefined` when this installation has none
|
||||
* (ADR 0037).
|
||||
*
|
||||
* `undefined` is a complete, valid deployment: nothing in the platform sends email yet, and a
|
||||
* future feature that needs to discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing.
|
||||
* `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.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||
* 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
|
||||
@@ -14,6 +14,10 @@ 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
|
||||
@@ -36,18 +40,24 @@ export class DisplayHostAssetError extends Error {
|
||||
* 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): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
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 two surface-declared files. A missing or unreadable build fails startup rather
|
||||
/** 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 {
|
||||
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
// 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");
|
||||
return createDisplayHostAssets(html, script);
|
||||
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
||||
return createDisplayHostAssets(html, script, serviceWorker);
|
||||
} catch {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -7,24 +7,37 @@ import {
|
||||
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 { grantedModulePermissionIds } from "./module-ui-inventory.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 MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||
|
||||
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
/**
|
||||
* Where 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
|
||||
@@ -34,9 +47,15 @@ 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,8 +119,11 @@ function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||
* bytes with a stale validator. */
|
||||
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||
try {
|
||||
const snapshot = createDisplayHostAssets(input.html, input.script);
|
||||
if (snapshot.scriptEtag !== input.scriptEtag) {
|
||||
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||
if (
|
||||
snapshot.scriptEtag !== input.scriptEtag ||
|
||||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||
) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
return snapshot;
|
||||
@@ -132,8 +154,10 @@ async function describeDisplay(
|
||||
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,
|
||||
@@ -244,6 +268,33 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -235,9 +235,12 @@ export function mountModuleHttpRoutes(
|
||||
|
||||
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 an oversized request never
|
||||
// reaches authorization work, let alone the handler or the module database.
|
||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
|
||||
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
|
||||
// never reaches authorization work, let alone the handler or the module database. In
|
||||
// `streamed` mode no body byte is read until the handler reads it, and the handler runs only
|
||||
// after authorization succeeds, so an unauthorized caller can never push an upload through
|
||||
// (ADR 0042).
|
||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||
const path = moduleRoutePath(route.moduleId, route.path);
|
||||
const finalHandler = async (c: Context): Promise<Response> => {
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules";
|
||||
import type {
|
||||
ModuleUiPublicationRegistry,
|
||||
PublishableModuleContribution,
|
||||
PublishableModuleUi,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
@@ -9,8 +13,13 @@ import {
|
||||
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, moduleUiArtifactUrl } from "./module-ui-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
|
||||
@@ -40,6 +49,10 @@ export interface HostModuleUiDescriptor {
|
||||
* 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
|
||||
@@ -64,12 +77,56 @@ export interface HostModuleUiDescriptor {
|
||||
* 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
|
||||
@@ -116,9 +173,13 @@ 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) },
|
||||
@@ -153,7 +214,7 @@ export async function describeMountableModules(
|
||||
): Promise<readonly MountableModuleDescription[]> {
|
||||
return Promise.all(
|
||||
registry.list().map(async (published) => ({
|
||||
descriptor: await describeModule(evaluator, principal, published),
|
||||
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||
name: published.name,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -89,6 +89,26 @@ 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
|
||||
@@ -191,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
|
||||
*
|
||||
* ## The request never touches the filesystem
|
||||
*
|
||||
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
|
||||
* `registry.resolve` 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,
|
||||
* `: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
|
||||
@@ -228,6 +248,24 @@ export interface CreateModuleUiRoutesOptions {
|
||||
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"),
|
||||
|
||||
+34
-16
@@ -13,7 +13,12 @@ import {
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createIdentityNotifications,
|
||||
createIdentityRuntime,
|
||||
type IdentityNotificationReporter,
|
||||
type IdentityRateLimitOverride,
|
||||
} from "@ekklesia/core/identity";
|
||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||
import {
|
||||
createModuleConfigurationStore,
|
||||
@@ -138,6 +143,15 @@ export interface StartApiOptions {
|
||||
/** Test-only escape hatch: never set by the executable entry point. See
|
||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||
/**
|
||||
* Where a verification or reset message that could not be delivered is reported.
|
||||
*
|
||||
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
|
||||
* seam because the *only* externally observable difference between "the relay is down" and "there
|
||||
* is no such account" must be this report — every HTTP response is deliberately identical — so a
|
||||
* test needs somewhere to observe the operational failure the user is never shown.
|
||||
*/
|
||||
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
@@ -430,6 +444,14 @@ export async function startApi(
|
||||
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 authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||
@@ -439,11 +461,22 @@ export async function startApi(
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.identity,
|
||||
db: databaseHandle.identity,
|
||||
// Identity's two outbound messages, composed here — the one place that knows both the
|
||||
// validated identity configuration and the host's mail provider. Core's identity code learns
|
||||
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
|
||||
notifications: createIdentityNotifications({
|
||||
authBaseUrl: config.identity.baseUrl,
|
||||
dashboardOrigin: config.identity.dashboardOrigin,
|
||||
mail: mailProvider,
|
||||
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
|
||||
}),
|
||||
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
||||
});
|
||||
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
||||
deviceStore = createDeviceStore(databaseHandle.devices);
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
@@ -460,21 +493,6 @@ export async function startApi(
|
||||
// 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.
|
||||
// The host's object-storage provider, constructed from validated configuration before any module
|
||||
// runs — and **without touching the network**. Building the client opens no connection, so a
|
||||
// storage outage can never stop the platform from booting; a bucket is ensured lazily on first
|
||||
// use instead (ADR 0030). An installation with no storage configured gets `undefined` here, which
|
||||
// is a complete, valid deployment.
|
||||
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
||||
|
||||
// The host's outbound mail provider, constructed from validated configuration and — like the
|
||||
// storage client above — **without touching the network**. Building a Nodemailer transport opens
|
||||
// no socket and sends nothing, so an unreachable relay can never stop the platform from booting;
|
||||
// the first connection happens when something actually sends (ADR 0037). Nothing sends yet: this
|
||||
// exists so Pass 2 can hand it to Better Auth's verification and reset hooks. An installation with
|
||||
// no SMTP configured gets `undefined`, which is a complete, valid deployment.
|
||||
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
||||
|
||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||
databaseHandle.moduleConfiguration,
|
||||
|
||||
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -329,7 +330,7 @@ async function createGatedRouteModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||
version: "0.1.0",
|
||||
@@ -373,7 +374,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -431,6 +432,39 @@ async function createFixtureModule(
|
||||
async handler(request, context) {
|
||||
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
||||
},
|
||||
});
|
||||
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
|
||||
// arrive and never materializes the body, which is exactly what a real module
|
||||
// storing an upload would do.
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/uploads",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 33554432,
|
||||
bodyMode: "streamed",
|
||||
async handler(request) {
|
||||
let bytes = 0;
|
||||
if (request.body) {
|
||||
const reader = request.body.getReader();
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) { break; }
|
||||
bytes += chunk.value.byteLength;
|
||||
}
|
||||
}
|
||||
return Response.json({ bytes }, { status: 201 });
|
||||
},
|
||||
});
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/small-uploads",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 64,
|
||||
bodyMode: "streamed",
|
||||
async handler(request) {
|
||||
await request.arrayBuffer().catch(() => undefined);
|
||||
return Response.json({ swallowed: true }, { status: 201 });
|
||||
},
|
||||
});`
|
||||
: ""
|
||||
}
|
||||
@@ -478,7 +512,7 @@ async function createUiModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -522,6 +556,32 @@ function apiOrigin(running: RunningApi): string {
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs
|
||||
* (ADR 0038).
|
||||
*
|
||||
* Deliberately not a weakening of the policy: it is the test-harness equivalent of an operator with
|
||||
* database credentials, used only so scenarios that are about something else are not obliged to run
|
||||
* an SMTP server. That the policy itself is enforced — that an unverified account genuinely cannot
|
||||
* sign in — is asserted separately, both in this file and in `email-verification.integration.ts`.
|
||||
*/
|
||||
async function markEmailVerified(origin: string, email: string): Promise<void> {
|
||||
const url = databaseUrlByOrigin.get(origin);
|
||||
if (!url) {
|
||||
throw harnessError("No database is registered for that API origin.");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
interface StartConfiguredApiOptions {
|
||||
readonly clientAddress?: ClientAddressConfigInput;
|
||||
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
|
||||
@@ -537,6 +597,16 @@ interface StartConfiguredApiOptions {
|
||||
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The database each running API in this suite is attached to, keyed by its bound origin.
|
||||
*
|
||||
* Needed because Phase 7C Pass 2 made a verified address a precondition for holding a session
|
||||
* (ADR 0038), so `signUp` below has to mark the account verified before these scenarios — which are
|
||||
* about cookies, CSRF, principals, sessions, and authorization, not about verification — can sign
|
||||
* in. Verification itself has its own dedicated end-to-end suite.
|
||||
*/
|
||||
const databaseUrlByOrigin = new Map<string, string>();
|
||||
|
||||
async function startConfiguredApi(
|
||||
url: string,
|
||||
options: StartConfiguredApiOptions = {},
|
||||
@@ -571,7 +641,7 @@ async function startConfiguredApi(
|
||||
},
|
||||
{ environment },
|
||||
);
|
||||
return startApi(config, {
|
||||
const running = await startApi(config, {
|
||||
...("rateLimitOverride" in options
|
||||
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
||||
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
||||
@@ -585,6 +655,8 @@ async function startConfiguredApi(
|
||||
? {}
|
||||
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
|
||||
});
|
||||
databaseUrlByOrigin.set(apiOrigin(running), url);
|
||||
return running;
|
||||
}
|
||||
|
||||
async function startMigratedApi(
|
||||
@@ -763,6 +835,12 @@ async function signUp(
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
// A verified address is a precondition for holding a session (ADR 0038). Sign-up itself always
|
||||
// writes `emailVerified: false`, so these scenarios — none of which are about verification —
|
||||
// complete that step out of band rather than standing up an SMTP server apiece.
|
||||
await markEmailVerified(origin, email);
|
||||
}
|
||||
return { body: (await response.clone().json()) as SignUpResult["body"], response };
|
||||
}
|
||||
|
||||
@@ -2640,6 +2718,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
||||
});
|
||||
|
||||
it("streams one image-sized upload end to end over real HTTP", async () => {
|
||||
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
|
||||
// travels through a real socket, a real Node server, and the platform's byte counter, and the
|
||||
// platform holds none of it (ADR 0042).
|
||||
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
|
||||
body: image,
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.json()).toEqual({ bytes: image.byteLength });
|
||||
}, 60_000);
|
||||
|
||||
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||
body: "a".repeat(65),
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
|
||||
// No declared `Content-Length`, so only the running byte count can bound this — and the
|
||||
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
|
||||
// wins: a limit a handler can swallow is not a limit.
|
||||
const oversize = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(512).fill(0x41));
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||
body: oversize,
|
||||
duplex: "half",
|
||||
headers: { cookie: ownerCookie, origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
}, 30_000);
|
||||
|
||||
it("keeps the server healthy after an oversized upload", async () => {
|
||||
// The socket was released rather than left draining, so the very next request is served
|
||||
// normally.
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
|
||||
});
|
||||
|
||||
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
|
||||
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
// Defaults to the identity base URL: the Dashboard and the API share one origin unless a
|
||||
// deployment separates them (ADR 0038).
|
||||
dashboardOrigin: "https://identity.example.test",
|
||||
publicSignupEnabled: false,
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
|
||||
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -269,7 +269,9 @@ describe("Dashboard navigation — reading", () => {
|
||||
|
||||
// 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" },
|
||||
|
||||
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
id: "announcements",
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ 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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -20,21 +21,28 @@ import {
|
||||
describe("display host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
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";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
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);
|
||||
});
|
||||
@@ -62,12 +70,15 @@ describe("display host asset loading", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty page or script as an unavailable host build", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
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)).toThrow(DisplayHostAssetError);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ function environment(
|
||||
|
||||
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]",
|
||||
@@ -77,6 +78,9 @@ describe("host configuration parity between startApi and Doctor", () => {
|
||||
|
||||
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",
|
||||
@@ -123,6 +127,10 @@ describe("host configuration parity between startApi and Doctor", () => {
|
||||
"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 }));
|
||||
|
||||
@@ -170,9 +170,13 @@ function bootstrapAgainst(url: string): BootstrapHandles {
|
||||
};
|
||||
}
|
||||
|
||||
/** The production posture: public signup disabled, exactly as a real deployment must run. */
|
||||
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||
const config = createApiRuntimeConfig(
|
||||
/**
|
||||
* 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",
|
||||
@@ -186,7 +190,10 @@ async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||
},
|
||||
{ environment: "development" },
|
||||
);
|
||||
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||
}
|
||||
|
||||
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
|
||||
}
|
||||
|
||||
function apiOrigin(running: RunningApi): string {
|
||||
@@ -270,6 +277,22 @@ async function countRows(url: string, table: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -685,3 +708,141 @@ describe.sequential("initial platform-owner bootstrap", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -254,12 +254,13 @@ function fakeDiscoveredModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions,
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"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 {
|
||||
@@ -253,6 +254,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"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(
|
||||
@@ -319,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
|
||||
return { userId: body.user.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
|
||||
*
|
||||
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
|
||||
* module database access rather than identity, so it completes that step directly rather than
|
||||
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
|
||||
* `email-verification.integration.ts`.
|
||||
*/
|
||||
async function markEmailVerified(url: string, email: string): Promise<void> {
|
||||
const client = new Client({ connectionString: url });
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(
|
||||
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
||||
[email.trim().toLowerCase()],
|
||||
);
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({ email, password }),
|
||||
@@ -507,6 +530,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||
|
||||
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
||||
await markEmailVerified(database.url, "[email protected]");
|
||||
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
||||
expect(signedUp.userId).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ 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,
|
||||
@@ -51,6 +52,7 @@ 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[] = [];
|
||||
@@ -101,7 +103,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: fixture.display
|
||||
? [
|
||||
@@ -266,11 +268,13 @@ describe("module display routes — authorization and descriptor", () => {
|
||||
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",
|
||||
@@ -348,9 +352,14 @@ describe("module display routes — role and lifecycle separation", () => {
|
||||
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: unknown[] })
|
||||
.modules,
|
||||
).toHaveLength(1);
|
||||
(
|
||||
(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 () => {
|
||||
@@ -476,7 +485,11 @@ describe("module display host asset publication", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
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>";
|
||||
@@ -497,4 +510,55 @@ describe("module display host asset publication", () => {
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,14 +58,17 @@ function fakeStore(): AuthorizationStore {
|
||||
};
|
||||
}
|
||||
|
||||
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
|
||||
/** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
|
||||
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
||||
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
||||
* suite further down overrides it explicitly to prove the contract itself. */
|
||||
* suite further down overrides them explicitly to prove the contract itself. */
|
||||
function moduleRoute(
|
||||
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
||||
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
||||
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
||||
maxBodyBytes?: number;
|
||||
},
|
||||
): RegisteredModuleHttpRoute {
|
||||
return Object.freeze({ maxBodyBytes: 1024, ...fields });
|
||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||
@@ -1634,3 +1637,375 @@ describe("module route overlap can never reach mounting", () => {
|
||||
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Explicit, bounded streaming uploads (ADR 0042).
|
||||
*
|
||||
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
|
||||
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
|
||||
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
|
||||
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
|
||||
*/
|
||||
describe("module route streamed body limits", () => {
|
||||
const oneMebibyte = 1024 * 1024;
|
||||
|
||||
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
|
||||
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
|
||||
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
|
||||
let sent = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (sent >= size) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const next = Math.min(chunkSize, size - sent);
|
||||
sent += next;
|
||||
controller.enqueue(new Uint8Array(next).fill(97));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function streamingApp(options: {
|
||||
readonly handler: ModuleRouteHandler;
|
||||
readonly maxBodyBytes: number;
|
||||
readonly evaluator?: AuthorizationEvaluator;
|
||||
}) {
|
||||
return buildModuleApp({
|
||||
...(options.evaluator ? { evaluator: options.evaluator } : {}),
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "streamed",
|
||||
handler: options.handler,
|
||||
maxBodyBytes: options.maxBodyBytes,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/uploads",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
|
||||
* "buffered then handed over". */
|
||||
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
|
||||
return async (request) => {
|
||||
const body = request.body;
|
||||
if (!body) {
|
||||
return Response.json({ bytes: 0 });
|
||||
}
|
||||
const reader = body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
received.bytes += value.byteLength;
|
||||
}
|
||||
return Response.json({ bytes: received.bytes });
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts a body of exactly the declared limit", async () => {
|
||||
const received = { bytes: 0 };
|
||||
const { app } = streamingApp({
|
||||
handler: await countingHandler(received),
|
||||
maxBodyBytes: oneMebibyte,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(oneMebibyte),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ bytes: oneMebibyte });
|
||||
});
|
||||
|
||||
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
|
||||
const received = { bytes: 0 };
|
||||
const counting = await countingHandler(received);
|
||||
const handler = vi.fn<ModuleRouteHandler>(counting);
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(2048, 16),
|
||||
duplex: "half",
|
||||
headers: { "content-length": String(1025), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
|
||||
// byte of the upload reached module code.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(received.bytes).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
|
||||
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
|
||||
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
|
||||
const received = { bytes: 0 };
|
||||
const { app } = streamingApp({
|
||||
handler: await countingHandler(received),
|
||||
maxBodyBytes: 64,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096, 32),
|
||||
duplex: "half",
|
||||
headers: { "content-length": "8", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
// It stopped promptly rather than draining the whole upload first.
|
||||
expect(received.bytes).toBeLessThanOrEqual(96);
|
||||
});
|
||||
|
||||
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
|
||||
// A limit a handler can swallow is not a limit.
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
try {
|
||||
await request.arrayBuffer();
|
||||
} catch {
|
||||
// Deliberately ignored, exactly as a careless module might.
|
||||
}
|
||||
return Response.json({ ok: "handler won" });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096, 32),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
|
||||
let released: (() => void) | undefined;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
released = resolve;
|
||||
});
|
||||
let firstChunkSeen = false;
|
||||
let secondChunkQueued = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (!secondChunkQueued) {
|
||||
secondChunkQueued = true;
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
return;
|
||||
}
|
||||
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
|
||||
// buffering platform would deadlock here rather than reach the assertion below.
|
||||
await finished;
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
|
||||
const first = await reader.read();
|
||||
firstChunkSeen = !first.done;
|
||||
released?.();
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(firstChunkSeen).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("releases an aborted upload without taking the platform down", async () => {
|
||||
const aborted = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
controller.error(new Error("client went away"));
|
||||
},
|
||||
});
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
await request.arrayBuffer();
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: aborted,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||
});
|
||||
|
||||
it("releases a body the handler never read", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
},
|
||||
});
|
||||
const { app } = streamingApp({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
maxBodyBytes: oneMebibyte,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "streamed",
|
||||
handler,
|
||||
maxBodyBytes: 64,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/uploads",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: "a".repeat(65),
|
||||
headers: { "content-length": "65", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(declaredOversize.status).toBe(413);
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
|
||||
// A within-limit body from a denied caller is refused by authorization, and the handler — the
|
||||
// only thing that ever reads a byte in streamed mode — never runs.
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(32, 8),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(response.status).toBe(403);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rejects an untrusted Origin before any body handling", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096),
|
||||
duplex: "half",
|
||||
headers: { origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
|
||||
const { app } = streamingApp({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
maxBodyBytes: 64,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: "secret-payload-".repeat(64),
|
||||
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
|
||||
expect(body).not.toContain("secret-payload");
|
||||
expect(body).not.toContain("64");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("still materializes a buffered body before the handler runs", async () => {
|
||||
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
|
||||
// body is already there and already within its declared allowance.
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
const text = await request.text();
|
||||
return Response.json({ length: text.length });
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "buffered",
|
||||
handler,
|
||||
maxBodyBytes: 128,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: chunkedBody(128, 16),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(withinLimit.status).toBe(200);
|
||||
expect(await withinLimit.json()).toEqual({ length: 128 });
|
||||
|
||||
const overLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: chunkedBody(129, 16),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(overLimit.status).toBe(413);
|
||||
// A buffered route's handler never sees an over-limit request at all.
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: "announcements",
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
discoverModules,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
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,
|
||||
@@ -121,7 +127,7 @@ async function createFixtureModule(
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
@@ -165,6 +171,7 @@ async function createFixtureModule(
|
||||
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) {
|
||||
@@ -184,6 +191,7 @@ async function createFixtureModule(
|
||||
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) {
|
||||
@@ -270,6 +278,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
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: {
|
||||
@@ -310,10 +319,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
|
||||
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
/** The loader hands the container straight to the module and never inspects it, so a plain sink is
|
||||
* enough and no DOM implementation is needed in this Node-environment suite. */
|
||||
function containerSink(): { textContent: string } {
|
||||
return { textContent: "" };
|
||||
/**
|
||||
* 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"];
|
||||
@@ -340,6 +374,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
// 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",
|
||||
@@ -365,13 +400,13 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
});
|
||||
|
||||
// 5. The module rendered, having reached its own backend through the host.
|
||||
expect(container.textContent).toBe("example:200:2");
|
||||
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(container.textContent).toBe("");
|
||||
expect(mountedText(container)).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
@@ -403,6 +438,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
}
|
||||
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",
|
||||
@@ -418,8 +455,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
);
|
||||
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||
|
||||
// 4. The generic loader receives the unchanged five-member browser context and mounts the
|
||||
// display artifact independently of the Dashboard artifact.
|
||||
// 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,
|
||||
@@ -427,6 +464,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
});
|
||||
expect(Object.keys(context).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
@@ -443,10 +482,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("example:display:200:2");
|
||||
expect(mountedText(container)).toBe("example:display:200:2");
|
||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mountedText(container)).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
@@ -514,7 +553,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
});
|
||||
|
||||
// Hiding a control is a usability improvement: the module simply did not ask.
|
||||
expect(container.textContent).toBe("example:denied");
|
||||
expect(mountedText(container)).toBe("example:denied");
|
||||
expect(harness.moduleApiCalls).toEqual([]);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
@@ -593,7 +632,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
version: groupedEntry.descriptor.version,
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toBe("example:200:2");
|
||||
expect(mountedText(container)).toBe("example:200:2");
|
||||
await mounted.unmount();
|
||||
|
||||
// Now move it somewhere else entirely.
|
||||
@@ -645,7 +684,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
version: movedEntry.descriptor.version,
|
||||
},
|
||||
});
|
||||
expect(movedContainer.textContent).toBe("example:200:2");
|
||||
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||
"GET /api/v1/modules/example/records",
|
||||
]);
|
||||
|
||||
@@ -59,6 +59,12 @@ interface ModuleFixture {
|
||||
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> {
|
||||
@@ -82,8 +88,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
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: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
@@ -103,6 +117,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
`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 {
|
||||
@@ -219,7 +242,9 @@ describe("module UI inventory — what it lists", () => {
|
||||
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" },
|
||||
@@ -315,6 +340,8 @@ describe("module UI inventory — what it lists", () => {
|
||||
// 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",
|
||||
@@ -520,3 +547,84 @@ describe("module UI inventory — it is not a module management API", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||
import { Window } from "happy-dom";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
MODULE_UI_ASSET_PREFIX,
|
||||
MODULE_UI_CACHE_CONTROL,
|
||||
MODULE_UI_CONTENT_TYPE,
|
||||
moduleContributionArtifactUrl,
|
||||
moduleDisplayUiArtifactUrl,
|
||||
moduleUiArtifactUrl,
|
||||
} from "../src/module-ui-routes.js";
|
||||
@@ -59,6 +62,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
* sequence whose exact publication and loader-compatibility can be asserted. */
|
||||
function artifactSource(id: string, version: string): string {
|
||||
return `export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: ${JSON.stringify(version)},
|
||||
mount(container, context) {
|
||||
@@ -83,6 +87,15 @@ interface ModuleFixture {
|
||||
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
||||
* files that genuinely exist on disk rather than against hypothetical ones. */
|
||||
readonly extraFiles?: Readonly<Record<string, string>>;
|
||||
/** Capabilities this module provides — what another module's contributions target. */
|
||||
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||
/** Browser renderers this module contributes to another module's capability. */
|
||||
readonly contributions?: readonly {
|
||||
readonly localName: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
readonly source: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
@@ -115,8 +128,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
},
|
||||
}),
|
||||
},
|
||||
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: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
@@ -139,6 +160,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
||||
}
|
||||
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"), contribution.source);
|
||||
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
|
||||
}
|
||||
if (fixture.display !== undefined) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||
@@ -209,6 +240,7 @@ async function install(
|
||||
displayHostAssets: createDisplayHostAssets(
|
||||
'<!doctype html><div id="display-root"></div>',
|
||||
"export {};\n",
|
||||
"export {};\n",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
@@ -862,6 +894,12 @@ describe("module UI artifact URL generation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface HostContainer {
|
||||
readonly children: { readonly length: number };
|
||||
querySelector(selectors: string): HostContainer | null;
|
||||
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||
}
|
||||
|
||||
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
||||
/**
|
||||
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
||||
@@ -880,21 +918,36 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader forwards the container untouched to the module's own `mount` and never inspects it,
|
||||
* so a plain sink is enough and no DOM implementation is required. The container's type is read
|
||||
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
|
||||
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
|
||||
* route exists to keep apart.
|
||||
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
||||
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
||||
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
||||
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
||||
*/
|
||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||
|
||||
function containerSink(): { textContent: string } {
|
||||
return { textContent: "" };
|
||||
/**
|
||||
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
|
||||
* real isolated root inside the container, and installing a DOM environment here would replace
|
||||
* the module runner's resolution for a server-side suite that dynamically imports real module
|
||||
* entrypoints.
|
||||
*/
|
||||
function containerSink(): HostContainer {
|
||||
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||
}
|
||||
|
||||
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||
function mountedText(container: HostContainer): string {
|
||||
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
|
||||
return (
|
||||
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hostContext() {
|
||||
return {
|
||||
api: { request: async () => new Response(null, { status: 200 }) },
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "announcements",
|
||||
permissions: { has: () => false },
|
||||
principal: { kind: "anonymous" as const },
|
||||
@@ -915,9 +968,9 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
source: { moduleId: "announcements", url, version: "1.4.2" },
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("announcements:mounted");
|
||||
expect(mountedText(container)).toBe("announcements:mounted");
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(container.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still rejects the published artifact when the host expects a different identity", async () => {
|
||||
@@ -935,3 +988,109 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
|
||||
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
|
||||
*/
|
||||
describe("module contribution artifact publication", () => {
|
||||
const signage = {
|
||||
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||
id: "signage",
|
||||
};
|
||||
const contributionSource = "export default { contributed: true };\n";
|
||||
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange,
|
||||
localName: "signage-slide",
|
||||
source: contributionSource,
|
||||
},
|
||||
],
|
||||
id: "announcements",
|
||||
});
|
||||
const contributionUrl = moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
"announcements.signage-slide",
|
||||
);
|
||||
|
||||
it("serves the declared contribution artifact byte for byte", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(contributionUrl);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
||||
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(await response.text()).toBe(contributionSource);
|
||||
});
|
||||
|
||||
it("builds a URL that cannot select a neighbouring file", () => {
|
||||
expect(contributionUrl).toBe(
|
||||
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||
);
|
||||
// The contribution id is one segment's worth of text, always — a value that needed encoding
|
||||
// simply fails to resolve.
|
||||
expect(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
"../../../etc/passwd",
|
||||
),
|
||||
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown contribution id", "announcements.nope"],
|
||||
["another module's contribution id", "signage.slide-renderer"],
|
||||
["a path traversal", "../../../etc/passwd"],
|
||||
["the artifact's source map", "announcements.signage-slide.map"],
|
||||
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
contributionId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
||||
});
|
||||
|
||||
it("refuses a version the platform does not have installed", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "9.9.9" },
|
||||
"announcements.signage-slide",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("publishes nothing when no installed module provides the target capability", async () => {
|
||||
const { app } = await install([announcements()]);
|
||||
|
||||
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("publishes nothing when the provider is outside the declared compatible range", async () => {
|
||||
const { app } = await install([signage, announcements("^2.0.0")]);
|
||||
|
||||
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("keeps the sibling source map and the module's own directory unreachable", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
|
||||
const response = await app.request(`${contributionUrl}${suffix}`);
|
||||
expect(response.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
"when": 1787543773057,
|
||||
"tag": "0006_device_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1787543773058,
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type InitialPlatformOwnerErrorCode =
|
||||
| "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"
|
||||
@@ -28,6 +29,8 @@ const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, strin
|
||||
"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.",
|
||||
@@ -131,6 +134,25 @@ async function findIdentityByEmail(
|
||||
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") {
|
||||
@@ -252,6 +274,29 @@ export async function createInitialPlatformOwner(
|
||||
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 ??
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { PermissionRegistryError } from "../authorization/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModuleSchemas,
|
||||
ModuleDiscoveryError,
|
||||
ModulePermissionCompositionError,
|
||||
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
|
||||
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(
|
||||
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
||||
);
|
||||
for (const discoveredModule of modules) {
|
||||
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"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleDatabase,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModuleSchemas,
|
||||
discoverModules,
|
||||
@@ -116,6 +117,7 @@ export interface DoctorReport {
|
||||
|
||||
export interface DoctorIdentityConfigurationOptions {
|
||||
readonly baseUrl?: string | undefined;
|
||||
readonly dashboardOrigin?: string | undefined;
|
||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||
readonly publicSignupEnabled?: string | undefined;
|
||||
readonly secret?: string | undefined;
|
||||
@@ -315,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
||||
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[] {
|
||||
const publication = createModuleUiPublicationRegistry(modules);
|
||||
const compatible = new Set(
|
||||
publication
|
||||
.listContributions()
|
||||
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||
);
|
||||
return modules.map((module) => {
|
||||
const id = module.manifest.id;
|
||||
const version = module.manifest.version;
|
||||
@@ -325,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
|
||||
) {
|
||||
throw new TypeError("Unsafe module diagnostic value.");
|
||||
}
|
||||
return `${id} ${version}`;
|
||||
const declared = module.contributions.length;
|
||||
if (declared === 0) {
|
||||
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)`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1193,7 +1223,7 @@ export async function diagnoseEkklesia(
|
||||
"skipped",
|
||||
"Outbound email not configured",
|
||||
[
|
||||
"No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.",
|
||||
"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 {
|
||||
|
||||
@@ -26,6 +26,9 @@ export type HostEnvironmentName = "development" | "production";
|
||||
*/
|
||||
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;
|
||||
@@ -118,6 +121,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
|
||||
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),
|
||||
@@ -129,6 +133,10 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
|
||||
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,
|
||||
|
||||
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
|
||||
| "BASE_URL_INVALID"
|
||||
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
||||
| "BASE_URL_MISSING"
|
||||
| "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"
|
||||
| "DASHBOARD_ORIGIN_INVALID"
|
||||
| "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"
|
||||
| "DASHBOARD_ORIGIN_UNTRUSTED"
|
||||
| "PUBLIC_SIGNUP_ENABLED_INVALID"
|
||||
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
|
||||
| "SECRET_LOW_ENTROPY"
|
||||
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
|
||||
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
||||
"The identity base URL must not resolve to a loopback host in production.",
|
||||
BASE_URL_MISSING: "The identity base URL is required.",
|
||||
DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.",
|
||||
DASHBOARD_ORIGIN_INVALID:
|
||||
"The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.",
|
||||
DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION:
|
||||
"The Dashboard origin must not resolve to a loopback host in production.",
|
||||
DASHBOARD_ORIGIN_UNTRUSTED:
|
||||
"The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.",
|
||||
PUBLIC_SIGNUP_ENABLED_INVALID:
|
||||
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
|
||||
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
||||
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
|
||||
|
||||
export interface IdentityConfig {
|
||||
readonly baseUrl: string;
|
||||
/**
|
||||
* The browser origin an emailed verification or password-reset link sends a person to.
|
||||
*
|
||||
* Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API
|
||||
* are served from one origin. It exists as its own value because they are not the same *concept* —
|
||||
* `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and
|
||||
* a deployment may separate them (an API at `https://api.example.org`, the Dashboard at
|
||||
* `https://dash.example.org`). It is deliberately **not** the public church website origin: this
|
||||
* is the authenticated Dashboard host and nothing else.
|
||||
*
|
||||
* Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so
|
||||
* a misconfiguration fails at startup rather than producing emailed links every recipient is
|
||||
* refused at.
|
||||
*/
|
||||
readonly dashboardOrigin: string;
|
||||
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
|
||||
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
||||
* loosely parsed. Development and test composition may enable it explicitly; production
|
||||
@@ -176,6 +202,8 @@ export interface IdentityConfig {
|
||||
|
||||
export interface IdentityConfigInput {
|
||||
readonly baseUrl?: unknown;
|
||||
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
|
||||
readonly dashboardOrigin?: unknown;
|
||||
readonly publicSignupEnabled?: unknown;
|
||||
readonly secret?: unknown;
|
||||
readonly trustedOrigins?: unknown;
|
||||
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the origin an emailed link points a browser at.
|
||||
*
|
||||
* Unset falls back to the identity base URL — the same-origin production shape — rather than
|
||||
* failing, because a deployment that has not separated its Dashboard from its API has nothing to
|
||||
* configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it
|
||||
* must be an origin Better Auth already trusts, because Better Auth validates the emailed callback
|
||||
* against `trustedOrigins` before redirecting. Checking it here converts a broken link every
|
||||
* recipient would hit into a startup failure the operator sees once.
|
||||
*/
|
||||
function validateDashboardOrigin(
|
||||
dashboardOrigin: unknown,
|
||||
baseUrl: string,
|
||||
trustedOrigins: readonly string[],
|
||||
environment: IdentityEnvironment,
|
||||
): string {
|
||||
if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") {
|
||||
return baseUrl;
|
||||
}
|
||||
const url = parseCanonicalHttpOrigin(dashboardOrigin);
|
||||
if (!url) {
|
||||
throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID");
|
||||
}
|
||||
if (environment === "production") {
|
||||
if (url.protocol !== "https:") {
|
||||
throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION");
|
||||
}
|
||||
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
|
||||
throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION");
|
||||
}
|
||||
}
|
||||
// `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the
|
||||
// base URL's origin to its trusted set (`getTrustedOrigins`, pinned release).
|
||||
if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) {
|
||||
throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
||||
if (typeof origin !== "string" || origin.length === 0) {
|
||||
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
||||
@@ -355,7 +422,19 @@ export function createIdentityConfig(
|
||||
const secret = validateSecret(input.secret);
|
||||
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
||||
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
||||
const dashboardOrigin = validateDashboardOrigin(
|
||||
input.dashboardOrigin,
|
||||
baseUrl,
|
||||
trustedOrigins,
|
||||
environment,
|
||||
);
|
||||
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
||||
|
||||
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
|
||||
return Object.freeze({
|
||||
baseUrl,
|
||||
dashboardOrigin,
|
||||
publicSignupEnabled,
|
||||
secret,
|
||||
trustedOrigins,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from "./bootstrap-identity.js";
|
||||
export * from "./client-address.js";
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
export * from "./notifications.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { MailError, type MailProvider, validateMailMessage } from "../mail/index.js";
|
||||
|
||||
/**
|
||||
* The identity feature's outbound messages, and the seam between Better Auth's callbacks and the
|
||||
* host's mail transport (ADR 0038).
|
||||
*
|
||||
* ## What this owns and what it deliberately does not
|
||||
*
|
||||
* It owns the recipient, the subject, and the two bodies. It does **not** know the SMTP host, the
|
||||
* port, the TLS mode, a credential, or the sender: those are host configuration held by the mail
|
||||
* provider, and there is no field on the message contract through which a caller could choose any
|
||||
* of them (ADR 0037). Swapping SMTP for a provider HTTP API would not change a line of this file.
|
||||
*
|
||||
* It also does not own tokens. Better Auth mints, persists (for reset), expires, and consumes them;
|
||||
* this receives one already-minted token and puts it in a link. There is no Ekklesia token table,
|
||||
* no Ekklesia token hashing, and no Ekklesia expiry clock — a second implementation of any of those
|
||||
* would be a second thing to get wrong.
|
||||
*
|
||||
* ## Why the link is rebuilt rather than used as given
|
||||
*
|
||||
* Better Auth hands these callbacks a prebuilt `url`, and that URL embeds whatever `callbackURL` or
|
||||
* `redirectTo` the *requesting client* supplied. Better Auth does validate those against its trusted
|
||||
* origins, and that validation is sound — but an emailed link is the one artifact a person is most
|
||||
* likely to click without looking, so its destination is derived here from validated configuration
|
||||
* alone and from nothing a request could influence. The token is the only value taken from Better
|
||||
* Auth. No request parameter can steer where a recipient lands, independently of whether the origin
|
||||
* check behaves as expected.
|
||||
*/
|
||||
|
||||
/** What could not be delivered, for an operator — never who it was for, and never the link. */
|
||||
export type IdentityNotificationKind = "password-reset" | "email-verification";
|
||||
|
||||
export interface IdentityNotificationFailure {
|
||||
readonly kind: IdentityNotificationKind;
|
||||
/** A sanitized `MailError` code, or `MAIL_SEND_FAILED` for anything unrecognized. */
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a delivery failure is reported.
|
||||
*
|
||||
* It exists because these callbacks must **never throw**. Better Auth's own
|
||||
* `/send-verification-email` rethrows whatever the callback threw, and a thrown error there turns
|
||||
* "this address has an unverified account" into a distinguishable response — an account-enumeration
|
||||
* oracle that appears the moment SMTP breaks. So failure is swallowed at this boundary and surfaced
|
||||
* out of band instead, where no HTTP response shape depends on it.
|
||||
*
|
||||
* The default implementation writes one fixed line per failure. It carries the flow and a sanitized
|
||||
* code and nothing else: no address, no token, no URL, no provider text.
|
||||
*/
|
||||
export type IdentityNotificationReporter = (failure: IdentityNotificationFailure) => void;
|
||||
|
||||
export interface IdentityNotificationRecipient {
|
||||
readonly email: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
export interface CreateIdentityNotificationsOptions {
|
||||
/**
|
||||
* The host mail provider, or `undefined` when this installation has none.
|
||||
*
|
||||
* `undefined` is a complete deployment (ADR 0037): the platform still runs, the initial owner
|
||||
* still signs in, and only verification and recovery are unavailable — reported as
|
||||
* `MAIL_NOT_CONFIGURED` through the reporter, never as a different HTTP response.
|
||||
*/
|
||||
readonly mail: MailProvider | undefined;
|
||||
/** Where the `/api/auth` transport lives — the origin Better Auth's own endpoints are on. */
|
||||
readonly authBaseUrl: string;
|
||||
/** Where a human's browser lands. Validated to be a trusted origin (`./config.js`). */
|
||||
readonly dashboardOrigin: string;
|
||||
/** Defaults to a single fixed sanitized console line. */
|
||||
readonly report?: IdentityNotificationReporter;
|
||||
}
|
||||
|
||||
export interface IdentityNotifications {
|
||||
/** Shaped for Better Auth's `emailVerification.sendVerificationEmail`. Never throws. */
|
||||
readonly sendVerificationEmail: (data: {
|
||||
readonly user: IdentityNotificationRecipient;
|
||||
readonly token: string;
|
||||
}) => Promise<void>;
|
||||
/** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */
|
||||
readonly sendResetPassword: (data: {
|
||||
readonly user: IdentityNotificationRecipient;
|
||||
readonly token: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two query values the Dashboard bundle reads to decide which host-owned auth screen to show.
|
||||
*
|
||||
* They are query parameters on the Dashboard's root path rather than distinct paths deliberately:
|
||||
* the shell is a static single-page bundle, and a deep path would require every deployment's static
|
||||
* host to rewrite unknown paths to `index.html` before an emailed link worked at all. A query
|
||||
* parameter works on any static host, including the repository's own development server.
|
||||
*/
|
||||
export const DASHBOARD_AUTH_LANDING_PARAMETER = "ekklesia_auth";
|
||||
export const DASHBOARD_VERIFY_EMAIL_LANDING = "verify-email";
|
||||
export const DASHBOARD_RESET_PASSWORD_LANDING = "reset-password";
|
||||
|
||||
function landingUrl(dashboardOrigin: string, landing: string): string {
|
||||
const url = new URL(dashboardOrigin);
|
||||
url.searchParams.set(DASHBOARD_AUTH_LANDING_PARAMETER, landing);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Better Auth's own verification URL, rebuilt from configuration.
|
||||
*
|
||||
* The shape is the pinned release's protocol exactly — `GET {authBase}/api/auth/verify-email?
|
||||
* token=...&callbackURL=...` — so Better Auth remains the only thing that inspects, expires, or acts
|
||||
* on the token. Only the destination is ours.
|
||||
*/
|
||||
function verificationUrl(options: CreateIdentityNotificationsOptions, token: string): string {
|
||||
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_VERIFY_EMAIL_LANDING);
|
||||
return `${options.authBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(
|
||||
token,
|
||||
)}&callbackURL=${encodeURIComponent(callback)}`;
|
||||
}
|
||||
|
||||
/** Better Auth's reset callback, rebuilt the same way. It validates the token, then redirects the
|
||||
* browser to the Dashboard landing with the token attached. */
|
||||
function resetUrl(options: CreateIdentityNotificationsOptions, token: string): string {
|
||||
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_RESET_PASSWORD_LANDING);
|
||||
return `${options.authBaseUrl}/api/auth/reset-password/${encodeURIComponent(
|
||||
token,
|
||||
)}?callbackURL=${encodeURIComponent(callback)}`;
|
||||
}
|
||||
|
||||
/** Escapes the four characters that could otherwise close an element or attribute in the HTML body.
|
||||
* Only the link and the greeting name are interpolated, and the name comes from the user record. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule `validateMailMessage` applies, expressed the same way it expresses it: a code-point
|
||||
* scan rather than a regular expression, because a regex over a control-character range is itself
|
||||
* something a linter (rightly) objects to.
|
||||
*/
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A greeting that is safe to place in a message body.
|
||||
*
|
||||
* A stored display name is not trusted to be free of control characters — the message validator
|
||||
* would reject the whole message for one stray carriage return, which would turn a cosmetic problem
|
||||
* into an undeliverable password reset. So an unusable name simply falls back to no greeting.
|
||||
*/
|
||||
function greeting(name: string | undefined): string {
|
||||
if (typeof name !== "string") {
|
||||
return "Hello,";
|
||||
}
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0 || trimmed.length > 64 || hasControlCharacter(trimmed)) {
|
||||
return "Hello,";
|
||||
}
|
||||
return `Hello ${trimmed},`;
|
||||
}
|
||||
|
||||
const EXPIRY_SENTENCE = "This link expires in one hour.";
|
||||
const UNEXPECTED_SENTENCE =
|
||||
"If you did not expect this message, you can ignore it and nothing will change.";
|
||||
|
||||
interface MessageBodies {
|
||||
readonly subject: string;
|
||||
readonly text: string;
|
||||
readonly html: string;
|
||||
}
|
||||
|
||||
function verificationBodies(name: string | undefined, url: string): MessageBodies {
|
||||
const lead = greeting(name);
|
||||
return {
|
||||
subject: "Verify your Ekklesia email address",
|
||||
html: [
|
||||
`<p>${escapeHtml(lead)}</p>`,
|
||||
"<p>Confirm this email address so it can be used to sign in to Ekklesia.</p>",
|
||||
`<p><a href="${escapeHtml(url)}">Verify this email address</a></p>`,
|
||||
`<p>${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}</p>`,
|
||||
].join("\n"),
|
||||
text: [
|
||||
lead,
|
||||
"",
|
||||
"Confirm this email address so it can be used to sign in to Ekklesia:",
|
||||
"",
|
||||
url,
|
||||
"",
|
||||
EXPIRY_SENTENCE,
|
||||
UNEXPECTED_SENTENCE,
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function resetBodies(name: string | undefined, url: string): MessageBodies {
|
||||
const lead = greeting(name);
|
||||
const closing = `${UNEXPECTED_SENTENCE} Your current password stays in place until a new one is chosen.`;
|
||||
return {
|
||||
subject: "Reset your Ekklesia password",
|
||||
html: [
|
||||
`<p>${escapeHtml(lead)}</p>`,
|
||||
"<p>Someone asked to reset the password for this Ekklesia account.</p>",
|
||||
`<p><a href="${escapeHtml(url)}">Choose a new password</a></p>`,
|
||||
`<p>${EXPIRY_SENTENCE} ${escapeHtml(closing)}</p>`,
|
||||
].join("\n"),
|
||||
text: [
|
||||
lead,
|
||||
"",
|
||||
"Someone asked to reset the password for this Ekklesia account. Choose a new one here:",
|
||||
"",
|
||||
url,
|
||||
"",
|
||||
EXPIRY_SENTENCE,
|
||||
closing,
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultReporter(failure: IdentityNotificationFailure): void {
|
||||
// One fixed line. The flow and a sanitized code are exactly what an operator needs in order to go
|
||||
// look at `pnpm doctor`; the address, the token, and the link are exactly what must never be here.
|
||||
console.error(`[identity] ${failure.kind} email was not delivered: ${failure.reason}`);
|
||||
}
|
||||
|
||||
function failureReason(error: unknown): string {
|
||||
return error instanceof MailError ? error.code : "MAIL_SEND_FAILED";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the two Better Auth callbacks.
|
||||
*
|
||||
* Neither rejects. A delivery failure is reported through `report` and the callback resolves, so the
|
||||
* HTTP response an unauthenticated caller sees is identical whether the address exists, whether it
|
||||
* was already verified, and whether the relay was reachable.
|
||||
*/
|
||||
export function createIdentityNotifications(
|
||||
options: CreateIdentityNotificationsOptions,
|
||||
): IdentityNotifications {
|
||||
const report = options.report ?? defaultReporter;
|
||||
|
||||
const deliver = async (
|
||||
kind: IdentityNotificationKind,
|
||||
recipient: IdentityNotificationRecipient,
|
||||
bodies: MessageBodies,
|
||||
): Promise<void> => {
|
||||
if (!options.mail) {
|
||||
report({ kind, reason: "MAIL_NOT_CONFIGURED" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await options.mail.send(
|
||||
validateMailMessage({
|
||||
html: bodies.html,
|
||||
subject: bodies.subject,
|
||||
text: bodies.text,
|
||||
to: recipient.email,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
report({ kind, reason: failureReason(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const notifications: IdentityNotifications = {
|
||||
sendResetPassword: async (data) => {
|
||||
await deliver(
|
||||
"password-reset",
|
||||
data.user,
|
||||
resetBodies(data.user.name, resetUrl(options, data.token)),
|
||||
);
|
||||
},
|
||||
sendVerificationEmail: async (data) => {
|
||||
await deliver(
|
||||
"email-verification",
|
||||
data.user,
|
||||
verificationBodies(data.user.name, verificationUrl(options, data.token)),
|
||||
);
|
||||
},
|
||||
};
|
||||
return Object.freeze(notifications);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { IdentityConfig } from "./config.js";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import type { IdentityNotifications } from "./notifications.js";
|
||||
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
||||
|
||||
export type IdentityRuntimeErrorCode =
|
||||
@@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
|
||||
*/
|
||||
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
|
||||
|
||||
/**
|
||||
* One hour for both, matching the pinned release's own defaults but stated rather than inherited.
|
||||
*
|
||||
* Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough
|
||||
* that someone who reads their mail after lunch is not sent back to the start. The message bodies
|
||||
* state the same hour, so the two can be checked against each other.
|
||||
*/
|
||||
const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600;
|
||||
const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600;
|
||||
|
||||
/**
|
||||
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which
|
||||
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
|
||||
@@ -82,10 +93,29 @@ export interface CreateIdentityRuntimeOptions {
|
||||
readonly config: IdentityConfig;
|
||||
readonly db: IdentityDrizzleDatabase;
|
||||
/**
|
||||
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test
|
||||
* composition that issues many such requests quickly. Rate limiting itself is always enabled;
|
||||
* production composition must leave this unset so Better Auth's own strict built-in default
|
||||
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified.
|
||||
* How verification and password-reset messages are delivered.
|
||||
*
|
||||
* Injected rather than constructed here, for the same reason the database is: this file composes
|
||||
* Better Auth and must not decide what a message says or how it travels. Omitting it is a
|
||||
* complete, valid deployment — email verification and password recovery are then configured
|
||||
* exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while
|
||||
* every HTTP response keeps its usual shape (ADR 0038).
|
||||
*
|
||||
* Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An
|
||||
* installation with no mail cannot verify a new account, which is why the initial platform owner
|
||||
* is created already verified by a trusted operator command rather than by an email round trip.
|
||||
*/
|
||||
readonly notifications?: IdentityNotifications;
|
||||
/**
|
||||
* A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats
|
||||
* more strictly than its global default, used only by test composition that issues many such
|
||||
* requests quickly. Rate limiting itself is always enabled; production composition must leave this
|
||||
* unset so Better Auth's own built-in defaults apply unmodified — 3 requests / 10 seconds on
|
||||
* sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification
|
||||
* resends, which are the two paths an attacker could otherwise use to send mail repeatedly.
|
||||
*
|
||||
* That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single
|
||||
* deployment and is deliberately **not** claimed to be a distributed one.
|
||||
*/
|
||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||
}
|
||||
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
|
||||
enabled: true,
|
||||
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
|
||||
// in the pinned release): an implicit default can change silently on a future Better Auth
|
||||
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary.
|
||||
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint
|
||||
// enforces the identical pair from the same configuration, so there is deliberately no
|
||||
// separate reset-time password policy.
|
||||
maxPasswordLength: 128,
|
||||
minPasswordLength: 8,
|
||||
/**
|
||||
* A person may not hold a session until their address is verified (ADR 0038).
|
||||
*
|
||||
* In the pinned release this is enforced *after* the password is checked and *before* any
|
||||
* session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set.
|
||||
* That is the whole reason no "unverified principal" exists anywhere in this platform — a
|
||||
* correct password on an unverified account produces no session at all, so there is no
|
||||
* half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to
|
||||
* describe only anonymous, authenticated human, and device.
|
||||
*/
|
||||
requireEmailVerification: true,
|
||||
/** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the
|
||||
* password bounds are pinned. */
|
||||
resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS,
|
||||
/**
|
||||
* Every existing session for that account is deleted when its password is reset.
|
||||
*
|
||||
* Password reset is the recovery path someone uses precisely when they suspect their
|
||||
* credential is compromised, and leaving the attacker's session alive would make the recovery
|
||||
* cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned
|
||||
* release), not a Core reach into the session table.
|
||||
*
|
||||
* It deletes *all* of that human's sessions, including the browser that performed the reset —
|
||||
* so the completion state returns to the sign-in form rather than pretending to be signed in.
|
||||
* Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no
|
||||
* password, and are entirely unaffected (ADR 0036).
|
||||
*/
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
...(options.notifications
|
||||
? { sendResetPassword: options.notifications.sendResetPassword }
|
||||
: {}),
|
||||
},
|
||||
/**
|
||||
* Verification is configured identically whether or not mail is deliverable, so the policy an
|
||||
* installation enforces never depends on the state of its relay.
|
||||
*/
|
||||
emailVerification: {
|
||||
/**
|
||||
* No session is minted from following an emailed link.
|
||||
*
|
||||
* A verification URL travels through mail infrastructure and is routinely fetched by things
|
||||
* that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning
|
||||
* that GET into a logged-in browser session would hand a session to whoever, or whatever,
|
||||
* dereferenced the link. Verification therefore only flips the flag, and the person signs in
|
||||
* afterwards through the ordinary form.
|
||||
*/
|
||||
autoSignInAfterVerification: false,
|
||||
/** Pinned rather than left on the release's `|| 3600` fallback. */
|
||||
expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS,
|
||||
/**
|
||||
* A sign-in attempt against an unverified account re-sends the verification message.
|
||||
*
|
||||
* This is the resend path that costs nothing to secure: reaching it requires the correct
|
||||
* password, so it reveals nothing an attacker did not already know, and it means the common
|
||||
* case — the first link expired — is handled by the thing the person was already trying to do.
|
||||
* The standalone `/send-verification-email` endpoint remains available for the case where they
|
||||
* would rather not retype a password, and is enumeration-safe on its own.
|
||||
*/
|
||||
sendOnSignIn: true,
|
||||
/**
|
||||
* Pinned rather than left on the release's "follow `requireEmailVerification`" fallback.
|
||||
*
|
||||
* It reaches nothing in production, where signup is refused outright — but stating it means a
|
||||
* development or test deployment that does enable signup behaves the way this platform
|
||||
* intends, rather than the way an inherited default happens to.
|
||||
*/
|
||||
sendOnSignUp: true,
|
||||
...(options.notifications
|
||||
? { sendVerificationEmail: options.notifications.sendVerificationEmail }
|
||||
: {}),
|
||||
},
|
||||
logger: {
|
||||
disabled: false,
|
||||
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
|
||||
...(rateLimitOverride
|
||||
? {
|
||||
customRules: {
|
||||
// The four paths Better Auth applies a stricter-than-global default to. Production
|
||||
// composition never sets this override, so those defaults — 3 requests / 10 seconds
|
||||
// on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an
|
||||
// email — remain in force there, unmodified.
|
||||
"/request-password-reset": {
|
||||
max: rateLimitOverride.max ?? 100,
|
||||
window: rateLimitOverride.window ?? 10,
|
||||
},
|
||||
"/send-verification-email": {
|
||||
max: rateLimitOverride.max ?? 100,
|
||||
window: rateLimitOverride.window ?? 10,
|
||||
},
|
||||
"/sign-in/email": {
|
||||
max: rateLimitOverride.max ?? 100,
|
||||
window: rateLimitOverride.window ?? 10,
|
||||
|
||||
+61
-1
@@ -8,6 +8,7 @@ export type MailConfigErrorCode =
|
||||
| "MAIL_FROM_ADDRESS_INVALID"
|
||||
| "MAIL_FROM_ADDRESS_MISSING"
|
||||
| "MAIL_FROM_NAME_INVALID"
|
||||
| "MAIL_HELO_NAME_INVALID"
|
||||
| "MAIL_HOST_INVALID"
|
||||
| "MAIL_PASSWORD_WITHOUT_USERNAME"
|
||||
| "MAIL_PORT_INVALID"
|
||||
@@ -19,6 +20,8 @@ const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object
|
||||
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
|
||||
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
|
||||
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
|
||||
MAIL_HELO_NAME_INVALID:
|
||||
"The SMTP HELO name must be a fully qualified domain name, with no scheme, port, brackets, or spaces.",
|
||||
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
|
||||
MAIL_PASSWORD_WITHOUT_USERNAME:
|
||||
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
|
||||
@@ -70,6 +73,7 @@ export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
|
||||
|
||||
export interface MailProviderConfigInput {
|
||||
readonly host?: string | undefined;
|
||||
readonly heloName?: string | undefined;
|
||||
readonly port?: string | number | undefined;
|
||||
readonly security?: string | undefined;
|
||||
readonly username?: string | undefined;
|
||||
@@ -87,6 +91,24 @@ export interface MailProviderConfigInput {
|
||||
*/
|
||||
export interface MailProviderConfig {
|
||||
readonly host: string;
|
||||
/**
|
||||
* The name this client identifies itself as in the SMTP `EHLO` command, when the deployment sets
|
||||
* one.
|
||||
*
|
||||
* This exists because of a real failure, and the failure is generic rather than tied to any one
|
||||
* provider. An SMTP client must greet the server with its own identity, and when nothing is
|
||||
* configured Nodemailer derives it from the machine's hostname — falling back to the address
|
||||
* literal `[127.0.0.1]` whenever that hostname is not fully qualified, which is the normal state
|
||||
* of a container or a VM nobody has named. Some relays refuse that greeting outright, closing the
|
||||
* connection with a `421` before any message is offered, which surfaces here as a bare
|
||||
* `MAIL_PROVIDER_UNAVAILABLE` and looks indistinguishable from the relay being down.
|
||||
*
|
||||
* Absent means Nodemailer's own derivation is used, unchanged. It is deliberately not defaulted to
|
||||
* the sender's domain: a host whose machine hostname *is* a valid FQDN may already be greeting
|
||||
* correctly — some relays check it against the connecting address's reverse DNS — and silently
|
||||
* replacing a working identity would trade one deployment's bug for another's.
|
||||
*/
|
||||
readonly heloName?: string;
|
||||
readonly port: number;
|
||||
readonly security: MailSecurityMode;
|
||||
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
|
||||
@@ -119,6 +141,29 @@ function validateHost(host: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully qualified domain name, and deliberately nothing else.
|
||||
*
|
||||
* Stricter than `HOST_PATTERN` on purpose, in two ways. It requires **at least one dot**, because a
|
||||
* single unqualified label is precisely the input Nodemailer already refuses to use — configuring
|
||||
* one would reproduce the failure this setting exists to fix. And it requires the final label to
|
||||
* begin with a letter, which rejects a bare IPv4 address: `EHLO 192.0.2.1` is not valid SMTP syntax,
|
||||
* and the bracketed address-literal form that *is* valid is the greeting the affected relays reject.
|
||||
*
|
||||
* Everything the requirement rules out falls out of the anchors and the character class rather than
|
||||
* from a list of special cases: a scheme, a port, a path, whitespace, brackets, a comma, a
|
||||
* carriage return or line feed, and any other prose all fail to match.
|
||||
*/
|
||||
const HELO_NAME_PATTERN =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
|
||||
|
||||
function validateHeloName(heloName: string): string {
|
||||
if (!HELO_NAME_PATTERN.test(heloName)) {
|
||||
throw new MailConfigError("MAIL_HELO_NAME_INVALID");
|
||||
}
|
||||
return heloName;
|
||||
}
|
||||
|
||||
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
|
||||
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
|
||||
return DEFAULT_PORTS[security];
|
||||
@@ -174,6 +219,16 @@ export function createMailProviderConfig(
|
||||
const validatedHost = validateHost(host);
|
||||
const port = validatePort(input?.port, security);
|
||||
|
||||
// Validated exactly as supplied, with no trimming. Every other value here tolerates surrounding
|
||||
// whitespace as a copy-paste artefact; this one must not, because a stray character in the value
|
||||
// goes straight onto the wire in a protocol command. It is refused rather than repaired, the same
|
||||
// rule an address follows.
|
||||
const heloNameInput = input?.heloName;
|
||||
const heloName =
|
||||
heloNameInput === undefined || heloNameInput === ""
|
||||
? undefined
|
||||
: validateHeloName(heloNameInput);
|
||||
|
||||
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
|
||||
const password = input?.password === "" ? undefined : input?.password;
|
||||
if (username !== undefined && password === undefined) {
|
||||
@@ -201,6 +256,7 @@ export function createMailProviderConfig(
|
||||
host: validatedHost,
|
||||
port,
|
||||
security,
|
||||
...(heloName === undefined ? {} : { heloName }),
|
||||
...(fromName === undefined ? {} : { fromName }),
|
||||
...(username === undefined || password === undefined ? {} : { password, username }),
|
||||
});
|
||||
@@ -219,7 +275,11 @@ export function describeMailProvider(config: MailProviderConfig): string {
|
||||
config.fromName === undefined
|
||||
? config.fromAddress
|
||||
: `${config.fromName} <${config.fromAddress}>`;
|
||||
// The HELO name is host infrastructure, not a credential: it is the identity this client announces
|
||||
// in the clear on every connection, so printing it costs nothing and is exactly what an operator
|
||||
// needs when a relay has refused the greeting.
|
||||
const helo = config.heloName === undefined ? "" : ` · HELO ${config.heloName}`;
|
||||
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
|
||||
config.username === undefined ? "unauthenticated relay" : "authenticated"
|
||||
}`;
|
||||
}${helo}`;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,11 @@ function transportOptions(
|
||||
disableUrlAccess: true,
|
||||
greetingTimeout: bounds.greetingTimeoutMs,
|
||||
host: config.host,
|
||||
// The identity this client announces in `EHLO`. Left to Nodemailer when the deployment
|
||||
// configures none — it derives one from the machine hostname, falling back to the address
|
||||
// literal `[127.0.0.1]` when that hostname is not fully qualified, which some relays refuse
|
||||
// with a `421` before a message is ever offered (ADR 0037).
|
||||
...(config.heloName === undefined ? {} : { name: config.heloName }),
|
||||
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
|
||||
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
|
||||
// platform sending a handful of transactional messages buys nothing and makes the failure modes
|
||||
|
||||
@@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue {
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One declared browser contribution, with its artifact resolved and validated exactly like every
|
||||
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
|
||||
* what it renders.
|
||||
*/
|
||||
export interface ResolvedModuleContribution {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
readonly artifactPath: string;
|
||||
}
|
||||
|
||||
export interface ResolvedModuleEntrypoints {
|
||||
readonly server: string;
|
||||
readonly schema?: string;
|
||||
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
||||
|
||||
export interface DiscoveredModule {
|
||||
readonly directory: string;
|
||||
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
|
||||
* inside this module package. Empty for a module that contributes nothing. */
|
||||
readonly contributions: readonly ResolvedModuleContribution[];
|
||||
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||
readonly manifest: ReadonlyModuleManifest;
|
||||
readonly manifestPath: string;
|
||||
@@ -110,6 +126,7 @@ interface ModulePackageData {
|
||||
}
|
||||
|
||||
interface ModuleCandidate {
|
||||
contributions: readonly ResolvedModuleContribution[];
|
||||
directory: string;
|
||||
directoryName: string;
|
||||
entrypoints: ResolvedModuleEntrypoints;
|
||||
@@ -257,13 +274,19 @@ function validatePackageData(
|
||||
return { name, version };
|
||||
}
|
||||
|
||||
interface ResolvedModuleArtifacts {
|
||||
readonly contributions: readonly ResolvedModuleContribution[];
|
||||
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||
}
|
||||
|
||||
async function resolveEntrypoints(
|
||||
moduleDirectory: string,
|
||||
directoryName: string,
|
||||
manifest: ModuleManifest,
|
||||
issues: ModuleDiscoveryIssue[],
|
||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
||||
): Promise<ResolvedModuleArtifacts | undefined> {
|
||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||
const resolvedContributions = new Map<string, string>();
|
||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
|
||||
issuePath: `entrypoints.${entrypointName}`,
|
||||
relativeEntrypoint,
|
||||
}));
|
||||
// A contribution artifact is resolved through the identical loop as every other compiled
|
||||
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
|
||||
// are one implementation, so a contribution can never quietly receive weaker checks than
|
||||
// `entrypoints.ui` (ADR 0041).
|
||||
const configuredContributions = manifest.contributions.map((contribution, index) => ({
|
||||
entrypointName: `contribution:${contribution.id}`,
|
||||
issuePath: `contributions.${index}.artifact`,
|
||||
relativeEntrypoint: contribution.artifact,
|
||||
}));
|
||||
const configuredEntrypoints = [
|
||||
...ordinaryConfiguredEntrypoints,
|
||||
...(display === undefined
|
||||
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
|
||||
relativeEntrypoint: display.artifact,
|
||||
},
|
||||
]),
|
||||
...configuredContributions,
|
||||
];
|
||||
let entrypointCollision = false;
|
||||
|
||||
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||
if (entrypointName.startsWith("contribution:")) {
|
||||
resolvedContributions.set(
|
||||
entrypointName.slice("contribution:".length),
|
||||
canonicalEntrypoint,
|
||||
);
|
||||
} else {
|
||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = errorCode(error);
|
||||
issues.push({
|
||||
@@ -394,11 +434,46 @@ async function resolveEntrypoints(
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved.server || entrypointCollision) {
|
||||
// A contribution artifact must be its own file. Two declarations resolving to one path — through
|
||||
// a link, since the manifest already rejects two identical declared paths — would make one
|
||||
// contribution silently serve another's bytes, and the loader's identity check would then reject
|
||||
// whichever one lost.
|
||||
const canonicalContributionPaths = new Map<string, string>();
|
||||
for (const [contributionId, canonicalPath] of resolvedContributions) {
|
||||
const claimedBy = canonicalContributionPaths.get(canonicalPath);
|
||||
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
|
||||
entrypointCollision = true;
|
||||
issues.push({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "contributions",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
canonicalContributionPaths.set(canonicalPath, contributionId);
|
||||
}
|
||||
|
||||
if (
|
||||
!resolved.server ||
|
||||
entrypointCollision ||
|
||||
resolvedContributions.size !== manifest.contributions.length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
const contributions = manifest.contributions.map((contribution) =>
|
||||
Object.freeze({
|
||||
artifactPath: resolvedContributions.get(contribution.id) as string,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: contribution.id,
|
||||
version: contribution.version,
|
||||
}),
|
||||
);
|
||||
|
||||
const entrypoints: ResolvedModuleEntrypoints = {
|
||||
server: resolved.server,
|
||||
...(resolved.schema ? { schema: resolved.schema } : {}),
|
||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return { contributions: Object.freeze(contributions), entrypoints };
|
||||
}
|
||||
|
||||
async function scanModuleDirectory(
|
||||
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
|
||||
});
|
||||
}
|
||||
|
||||
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||
if (!entrypoints) {
|
||||
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||
if (!artifacts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
contributions: artifacts.contributions,
|
||||
directory: canonicalModuleDirectory,
|
||||
directoryName,
|
||||
entrypoints,
|
||||
entrypoints: artifacts.entrypoints,
|
||||
manifest,
|
||||
manifestPath: canonicalManifestPath,
|
||||
packageName: packageData.name,
|
||||
|
||||
@@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_ROLLBACK_TIMEOUT"
|
||||
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||
| "MODULE_ROUTE_DUPLICATE"
|
||||
| "MODULE_ROUTE_BODY_MODE_INVALID"
|
||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||
| "MODULE_ROUTE_METHOD_INVALID"
|
||||
| "MODULE_ROUTE_OVERLAP"
|
||||
@@ -467,6 +468,7 @@ class StagedModuleHttpRoutes {
|
||||
shapes.add(shapeKey);
|
||||
local.push(
|
||||
Object.freeze({
|
||||
bodyMode: parsed.data.bodyMode,
|
||||
handler: parsed.data.handler,
|
||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||
method: parsed.data.method,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
type PermissionDeclaration,
|
||||
type PermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { DiscoveredModule } from "./discovery.js";
|
||||
|
||||
/**
|
||||
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
|
||||
readonly permissions: readonly PermissionDeclaration[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed module's browser contribution that the platform is willing to publish (ADR 0041).
|
||||
*
|
||||
* Publishable means three things, and nothing beyond them: the contributing module survived
|
||||
* discovery and is in the eligible set, some **other** eligible module provides the targeted
|
||||
* capability, and that provider's declared capability version satisfies the contribution's declared
|
||||
* range. A contribution whose capability nobody provides, or whose provider moved outside its range,
|
||||
* is simply absent — never published-but-broken, and never mounted with a shrug.
|
||||
*
|
||||
* Note what is *not* here. There is no payload schema, no slot, no ordering, no rendering hint, and
|
||||
* no domain vocabulary of any kind: Core carries identity, version, compatibility, and one file
|
||||
* path. What a contributed renderer draws, and what shape its input takes, are the consumer module's
|
||||
* business and travel between the two modules in the browser without passing through the platform.
|
||||
*/
|
||||
export interface PublishableModuleContribution {
|
||||
/** The module that *contributes* the renderer. */
|
||||
readonly moduleId: string;
|
||||
readonly moduleVersion: string;
|
||||
readonly contributionId: string;
|
||||
/** The contribution's own version, independent of its module's. */
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
/** The version the providing module declares for that capability — the value the contribution's
|
||||
* declared range was actually checked against. */
|
||||
readonly capabilityVersion: string;
|
||||
readonly artifactPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
||||
*
|
||||
@@ -89,6 +121,38 @@ export interface ModuleUiPublicationRegistry {
|
||||
* a display merely because a caller appends `/display` to its publication URL.
|
||||
*/
|
||||
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
||||
/** Every publishable browser contribution, in discovery order then manifest order. Frozen. */
|
||||
listContributions(): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* Every publishable contribution targeting one capability, in the same stable order.
|
||||
*
|
||||
* This is the lookup a *consumer* module's descriptor is built from, and the reason a consumer can
|
||||
* only ever learn about renderers aimed at a capability it actually provides: the caller passes
|
||||
* its own declared capability id and gets back exactly the contributions for it.
|
||||
*/
|
||||
contributionsForCapability(capabilityId: unknown): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* Every publishable contribution aimed at **any** capability the named module provides.
|
||||
*
|
||||
* This is the one lookup a descriptor for a consuming module is built from. Framing it per module
|
||||
* rather than per capability is what keeps capability ownership inside Core: a caller hands over a
|
||||
* module id and gets that module's own extension points filled in, with no way to ask about
|
||||
* someone else's.
|
||||
*/
|
||||
contributionsForModule(moduleId: unknown): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* The publishable record for an exact contributing module id, installed module version, and
|
||||
* contribution id, or `undefined`.
|
||||
*
|
||||
* All three must match. A contribution that is not installed, one whose module is at a different
|
||||
* version, one whose capability has no compatible provider, and an id that was never declared are
|
||||
* all indistinguishable here — each returns `undefined`.
|
||||
*/
|
||||
resolveContribution(
|
||||
moduleId: unknown,
|
||||
moduleVersion: unknown,
|
||||
contributionId: unknown,
|
||||
): PublishableModuleContribution | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,6 +188,41 @@ export function createModuleUiPublicationRegistry(
|
||||
): ModuleUiPublicationRegistry {
|
||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
||||
const contributions: PublishableModuleContribution[] = [];
|
||||
// Every capability the eligible set actually provides, at the version its provider declares. Built
|
||||
// from the same list everything else here is built from, so a contribution can never be checked
|
||||
// against a provider the platform is not running.
|
||||
const capabilityVersions = new Map<string, string>();
|
||||
for (const module of modules) {
|
||||
for (const capability of module.manifest.capabilities) {
|
||||
capabilityVersions.set(capability.id, capability.version);
|
||||
}
|
||||
}
|
||||
|
||||
for (const module of modules) {
|
||||
for (const contribution of module.contributions) {
|
||||
const capabilityVersion = capabilityVersions.get(contribution.capability);
|
||||
// Fails closed on both halves: an absent provider and an incompatible one are equally
|
||||
// unpublishable, so a range mismatch can never be papered over by mounting anyway.
|
||||
if (
|
||||
capabilityVersion === undefined ||
|
||||
!isVersionCompatible(capabilityVersion, contribution.capabilityRange)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
contributions.push(
|
||||
Object.freeze({
|
||||
artifactPath: contribution.artifactPath,
|
||||
capability: contribution.capability,
|
||||
capabilityVersion,
|
||||
contributionId: contribution.id,
|
||||
moduleId: module.manifest.id,
|
||||
moduleVersion: module.manifest.version,
|
||||
version: contribution.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const module of modules) {
|
||||
const artifactPath = module.entrypoints.ui;
|
||||
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
|
||||
|
||||
const published = Object.freeze([...byModuleId.values()]);
|
||||
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
||||
const publishedContributions = Object.freeze([...contributions]);
|
||||
const contributionsByCapability = new Map<string, PublishableModuleContribution[]>();
|
||||
const contributionsByKey = new Map<string, PublishableModuleContribution>();
|
||||
for (const contribution of publishedContributions) {
|
||||
const forCapability = contributionsByCapability.get(contribution.capability) ?? [];
|
||||
forCapability.push(contribution);
|
||||
contributionsByCapability.set(contribution.capability, forCapability);
|
||||
// Module id and contribution id are both namespaced and unique, so this key cannot collide.
|
||||
contributionsByKey.set(
|
||||
`${contribution.moduleId}\u0000${contribution.contributionId}`,
|
||||
contribution,
|
||||
);
|
||||
}
|
||||
const frozenByCapability = new Map(
|
||||
[...contributionsByCapability].map(([capability, list]) => [capability, Object.freeze(list)]),
|
||||
);
|
||||
const noContributions: readonly PublishableModuleContribution[] = Object.freeze([]);
|
||||
const byConsumingModule = new Map<string, readonly PublishableModuleContribution[]>(
|
||||
modules.map((module) => [
|
||||
module.manifest.id,
|
||||
Object.freeze(
|
||||
module.manifest.capabilities.flatMap(
|
||||
(capability) => contributionsByCapability.get(capability.id) ?? [],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
contributionsForCapability: (
|
||||
capabilityId: unknown,
|
||||
): readonly PublishableModuleContribution[] =>
|
||||
typeof capabilityId === "string"
|
||||
? (frozenByCapability.get(capabilityId) ?? noContributions)
|
||||
: noContributions,
|
||||
contributionsForModule: (moduleId: unknown): readonly PublishableModuleContribution[] =>
|
||||
typeof moduleId === "string"
|
||||
? (byConsumingModule.get(moduleId) ?? noContributions)
|
||||
: noContributions,
|
||||
listContributions: (): readonly PublishableModuleContribution[] => publishedContributions,
|
||||
resolveContribution: (
|
||||
moduleId: unknown,
|
||||
moduleVersion: unknown,
|
||||
contributionId: unknown,
|
||||
): PublishableModuleContribution | undefined => {
|
||||
if (
|
||||
typeof moduleId !== "string" ||
|
||||
typeof moduleVersion !== "string" ||
|
||||
typeof contributionId !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = contributionsByKey.get(`${moduleId}\u0000${contributionId}`);
|
||||
// Exact installed-version equality, exactly as the UI and display lookups require: a request
|
||||
// naming a version the platform does not have installed resolves to nothing at all.
|
||||
return candidate?.moduleVersion === moduleVersion ? candidate : undefined;
|
||||
},
|
||||
list: (): readonly PublishableModuleUi[] => published,
|
||||
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||
|
||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"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 {
|
||||
|
||||
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
: {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"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 {
|
||||
|
||||
@@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({
|
||||
|
||||
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||
manifest: {
|
||||
@@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -363,6 +364,36 @@ describe("Doctor host-configuration parity — outbound email regression", () =>
|
||||
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
||||
});
|
||||
|
||||
it("probes with the SMTP client identity the runtime would use, and reports it", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const { report } = await diagnose(
|
||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "mail.example.org" }),
|
||||
services,
|
||||
);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
// Doctor builds its provider from the same validated configuration `startApi` does, so a relay
|
||||
// that refuses the greeting fails here rather than only when a reset email silently never
|
||||
// arrives. The name is announced in the clear on every connection, so showing it leaks nothing.
|
||||
expect(services.probeMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ heloName: "mail.example.org" }),
|
||||
);
|
||||
expect(mailCheck.details[0]).toContain("HELO mail.example.org");
|
||||
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
||||
});
|
||||
|
||||
it("reports a malformed SMTP client identity as a typed failure, naming no value", async () => {
|
||||
const { report } = await diagnose(
|
||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "smtp://internal-host:2525" }),
|
||||
);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("failure");
|
||||
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_HELO_NAME_INVALID.");
|
||||
expect(JSON.stringify(mailCheck)).not.toContain("internal-host");
|
||||
});
|
||||
|
||||
it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => {
|
||||
const { report } = await diagnose(
|
||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }),
|
||||
|
||||
@@ -126,7 +126,7 @@ Object storage
|
||||
|
||||
Outbound email
|
||||
○ Outbound email not configured
|
||||
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
|
||||
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`);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
|
||||
|
||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||
manifest: {
|
||||
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -1471,3 +1472,91 @@ describe("Ekklesia Doctor — object storage", () => {
|
||||
expect(check(report, "object-storage.bindings").status).toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A browser contribution whose target capability nobody provides is otherwise silent: the manifest
|
||||
* is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041).
|
||||
* Doctor names it.
|
||||
*/
|
||||
describe("Doctor reports declared browser contributions", () => {
|
||||
function contributing(
|
||||
id: string,
|
||||
contribution: { capability: string; capabilityRange: string },
|
||||
): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
contributions: [
|
||||
{
|
||||
artifactPath: `/unpublished/${id}/contributions/slide.js`,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: `${id}.slide`,
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function providing(id: string, capability: { id: string; version: string }): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"],
|
||||
};
|
||||
}
|
||||
|
||||
async function detailsFor(modules: readonly DiscoveredModule[]): Promise<readonly string[]> {
|
||||
const report = await diagnoseEkklesia(
|
||||
options(),
|
||||
dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([...modules])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate(modules)),
|
||||
}),
|
||||
);
|
||||
return check(report, "modules.discovery").details;
|
||||
}
|
||||
|
||||
it("says nothing extra for a module that contributes nothing", async () => {
|
||||
expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]);
|
||||
});
|
||||
|
||||
it("reports a contribution whose provider satisfies its declared range", async () => {
|
||||
const details = await detailsFor([
|
||||
providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }),
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(details).toEqual([
|
||||
"signage 0.1.0",
|
||||
"announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => {
|
||||
expect(
|
||||
await detailsFor([
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]),
|
||||
).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]);
|
||||
|
||||
expect(
|
||||
await detailsFor([
|
||||
providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }),
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]),
|
||||
).toEqual([
|
||||
"signage 0.1.0",
|
||||
"announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 6,
|
||||
"manifestVersion": 7,
|
||||
"id": "db-facade",
|
||||
"name": "Database Facade Integration Test Module",
|
||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||
|
||||
@@ -62,6 +62,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
||||
"EKKLESIA_CLIENT_ADDRESS_HEADER",
|
||||
"EKKLESIA_CLIENT_ADDRESS_MODE",
|
||||
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
|
||||
"EKKLESIA_DASHBOARD_ORIGIN",
|
||||
"EKKLESIA_DATABASE_CA",
|
||||
"EKKLESIA_DATABASE_TLS_MODE",
|
||||
"EKKLESIA_IDENTITY_BASE_URL",
|
||||
@@ -70,6 +71,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
||||
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
||||
"EKKLESIA_SMTP_FROM_ADDRESS",
|
||||
"EKKLESIA_SMTP_FROM_NAME",
|
||||
"EKKLESIA_SMTP_HELO_NAME",
|
||||
"EKKLESIA_SMTP_HOST",
|
||||
"EKKLESIA_SMTP_PASSWORD",
|
||||
"EKKLESIA_SMTP_PORT",
|
||||
|
||||
@@ -20,6 +20,7 @@ const productionEnvironment = Object.freeze({
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
||||
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||
EKKLESIA_SMTP_FROM_NAME: "Ekklesia",
|
||||
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||
EKKLESIA_SMTP_PASSWORD: "sup3r-secret-relay-password",
|
||||
EKKLESIA_SMTP_PORT: "587",
|
||||
@@ -67,6 +68,7 @@ describe("host configuration loading", () => {
|
||||
expect(host.mail).toEqual({
|
||||
fromAddress: "[email protected]",
|
||||
fromName: "Ekklesia",
|
||||
heloName: "mail.example.org",
|
||||
host: "smtp.example.org",
|
||||
password: "sup3r-secret-relay-password",
|
||||
port: "587",
|
||||
@@ -80,6 +82,9 @@ describe("host configuration loading", () => {
|
||||
});
|
||||
expect(relayByAddress.mail.username).toBeUndefined();
|
||||
expect(relayByAddress.mail.password).toBeUndefined();
|
||||
// The SMTP client identity is optional too, and unset is the ordinary case: it matters only
|
||||
// where the machine's own hostname is not a fully qualified name the relay accepts.
|
||||
expect(relayByAddress.mail.heloName).toBeUndefined();
|
||||
// A source-IP-authorized relay has no credentials at all, and that must still validate.
|
||||
expect(createMailProviderConfig(relayByAddress.mail)).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -26,6 +26,9 @@ describe("Core identity configuration", () => {
|
||||
|
||||
expect(config).toEqual({
|
||||
baseUrl: validBaseUrl,
|
||||
// Unset: the Dashboard is served from the identity base URL, which is the ordinary
|
||||
// same-origin production shape (ADR 0038).
|
||||
dashboardOrigin: validBaseUrl,
|
||||
publicSignupEnabled: false,
|
||||
secret: validSecret,
|
||||
trustedOrigins: validTrustedOrigins,
|
||||
@@ -419,3 +422,108 @@ describe("Core identity configuration", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core identity Dashboard origin", () => {
|
||||
/**
|
||||
* Where an emailed verification or reset link sends a browser (ADR 0038).
|
||||
*
|
||||
* The rules exist because a link in an email is the one artifact nobody inspects before clicking:
|
||||
* it must land on the deployment's own Dashboard, over https in production, at an origin Better
|
||||
* Auth will actually accept as a redirect target — and a misconfiguration must fail at startup
|
||||
* rather than produce a link every recipient is refused at.
|
||||
*/
|
||||
it("defaults to the identity base URL, the ordinary same-origin production shape", () => {
|
||||
expect(createIdentityConfig(validInput()).dashboardOrigin).toBe(validBaseUrl);
|
||||
expect(createIdentityConfig({ ...validInput(), dashboardOrigin: "" }).dashboardOrigin).toBe(
|
||||
validBaseUrl,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a separate Dashboard origin that is also a trusted origin", () => {
|
||||
const config = createIdentityConfig({
|
||||
...validInput(),
|
||||
dashboardOrigin: "https://dashboard.example.test",
|
||||
});
|
||||
|
||||
expect(config.dashboardOrigin).toBe("https://dashboard.example.test");
|
||||
// Separate from the API origin, deliberately: the two are different concepts and a deployment
|
||||
// may split them.
|
||||
expect(config.dashboardOrigin).not.toBe(config.baseUrl);
|
||||
});
|
||||
|
||||
it("refuses an origin Better Auth would not accept as a redirect target", () => {
|
||||
// The failure this prevents is silent and total: Better Auth validates the emailed callback
|
||||
// against its trusted origins, so an untrusted Dashboard origin breaks every link it sends.
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://elsewhere.example.test" });
|
||||
throw new Error("expected the untrusted Dashboard origin to be refused");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_UNTRUSTED" });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses anything that is not a canonical origin", () => {
|
||||
for (const value of [
|
||||
"dashboard.example.test",
|
||||
"https://dashboard.example.test/",
|
||||
"https://dashboard.example.test/dashboard",
|
||||
"https://user:[email protected]",
|
||||
"https://dashboard.example.test?a=b",
|
||||
"javascript:alert(1)",
|
||||
"//dashboard.example.test",
|
||||
" https://dashboard.example.test",
|
||||
42,
|
||||
]) {
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), dashboardOrigin: value });
|
||||
throw new Error(`expected ${String(value)} to be refused`);
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INVALID" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("requires https and a non-loopback host in production, and relaxes both in development", () => {
|
||||
try {
|
||||
createIdentityConfig(
|
||||
// Structural and policy validity are checked before membership, so a plaintext origin is
|
||||
// reported as plaintext rather than as "untrusted" — which would send an operator looking
|
||||
// in the wrong place.
|
||||
{ ...validInput(), dashboardOrigin: "http://dashboard.example.test" },
|
||||
{ environment: "production" },
|
||||
);
|
||||
throw new Error("expected plaintext to be refused in production");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION" });
|
||||
}
|
||||
|
||||
try {
|
||||
createIdentityConfig(
|
||||
{ ...validInput(), dashboardOrigin: "https://localhost" },
|
||||
{ environment: "production" },
|
||||
);
|
||||
throw new Error("expected loopback to be refused in production");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION" });
|
||||
}
|
||||
|
||||
const development = createIdentityConfig(
|
||||
{
|
||||
...developmentInput(),
|
||||
dashboardOrigin: "http://localhost:5173",
|
||||
trustedOrigins: ["http://localhost:3000", "http://localhost:5173"],
|
||||
},
|
||||
{ environment: "development" },
|
||||
);
|
||||
expect(development.dashboardOrigin).toBe("http://localhost:5173");
|
||||
});
|
||||
|
||||
it("never carries the offending origin in the thrown error", () => {
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://evil.example.test" });
|
||||
throw new Error("expected a refusal");
|
||||
} catch (error) {
|
||||
expect(JSON.stringify({ message: (error as Error).message })).not.toContain("evil");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createIdentityNotifications,
|
||||
type IdentityNotificationFailure,
|
||||
} from "../src/identity/notifications.js";
|
||||
import { MailError, type MailProvider, type ValidatedMailMessage } from "../src/mail/index.js";
|
||||
|
||||
/**
|
||||
* The identity feature's two outbound messages (ADR 0038).
|
||||
*
|
||||
* The properties that matter here are all negative ones: that a link is built from configuration
|
||||
* rather than from anything a request supplied, that a token never reaches a log, that a delivery
|
||||
* failure never becomes an observable difference between "this address has an account" and "it does
|
||||
* not", and that identity code learns nothing about SMTP.
|
||||
*/
|
||||
const authBaseUrl = "https://api.example.test";
|
||||
const dashboardOrigin = "https://dashboard.example.test";
|
||||
const token = "verification-token-abc123";
|
||||
|
||||
interface Harness {
|
||||
readonly sent: ValidatedMailMessage[];
|
||||
readonly failures: IdentityNotificationFailure[];
|
||||
readonly notifications: ReturnType<typeof createIdentityNotifications>;
|
||||
}
|
||||
|
||||
function harness(
|
||||
options: { readonly mail?: MailProvider | undefined; readonly configured?: boolean } = {},
|
||||
): Harness {
|
||||
const sent: ValidatedMailMessage[] = [];
|
||||
const failures: IdentityNotificationFailure[] = [];
|
||||
const provider: MailProvider = options.mail ?? {
|
||||
close: async () => undefined,
|
||||
send: async (message) => {
|
||||
sent.push(message);
|
||||
return { messageId: "provider-message-id" };
|
||||
},
|
||||
verify: async () => undefined,
|
||||
};
|
||||
return {
|
||||
failures,
|
||||
notifications: createIdentityNotifications({
|
||||
authBaseUrl,
|
||||
dashboardOrigin,
|
||||
mail: options.configured === false ? undefined : provider,
|
||||
report: (failure) => failures.push(failure),
|
||||
}),
|
||||
sent,
|
||||
};
|
||||
}
|
||||
|
||||
function only(sent: readonly ValidatedMailMessage[]): ValidatedMailMessage {
|
||||
expect(sent).toHaveLength(1);
|
||||
return sent[0] as ValidatedMailMessage;
|
||||
}
|
||||
|
||||
/** Both bodies, so an assertion can never pass because it only looked at the plaintext one. */
|
||||
function bodies(message: ValidatedMailMessage): string {
|
||||
return `${message.text ?? ""}\n${message.html ?? ""}`;
|
||||
}
|
||||
|
||||
describe("identity verification email", () => {
|
||||
it("sends one message to the account's own address, with both bodies", async () => {
|
||||
const test = harness();
|
||||
|
||||
await test.notifications.sendVerificationEmail({
|
||||
token,
|
||||
user: { email: "[email protected]", name: "Sam" },
|
||||
});
|
||||
|
||||
const message = only(test.sent);
|
||||
expect(message.to).toEqual(["[email protected]"]);
|
||||
expect(message.subject).toBe("Verify your Ekklesia email address");
|
||||
expect(message.text).toContain("Hello Sam,");
|
||||
expect(message.html).toContain("<p>Hello Sam,</p>");
|
||||
expect(bodies(message)).toContain("This link expires in one hour.");
|
||||
expect(bodies(message)).toContain("you can ignore it");
|
||||
});
|
||||
|
||||
it("builds the link from configuration alone, on the Dashboard origin", async () => {
|
||||
const test = harness();
|
||||
|
||||
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
|
||||
|
||||
const text = only(test.sent).text ?? "";
|
||||
const link = text.split("\n").find((line) => line.startsWith("https://")) ?? "";
|
||||
const url = new URL(link);
|
||||
// Better Auth's own endpoint, so Better Auth remains the only thing that acts on the token.
|
||||
expect(url.origin).toBe(authBaseUrl);
|
||||
expect(url.pathname).toBe("/api/auth/verify-email");
|
||||
expect(url.searchParams.get("token")).toBe(token);
|
||||
// ...and the destination it will redirect to is the Dashboard, never the API origin.
|
||||
const callback = new URL(url.searchParams.get("callbackURL") ?? "");
|
||||
expect(callback.origin).toBe(dashboardOrigin);
|
||||
expect(callback.searchParams.get("ekklesia_auth")).toBe("verify-email");
|
||||
});
|
||||
|
||||
it("never names the sender, the relay, or any transport detail", async () => {
|
||||
const test = harness();
|
||||
|
||||
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
|
||||
|
||||
// The message contract has no `from` field at all, so identity code cannot choose a sender.
|
||||
expect(Object.keys(only(test.sent))).not.toContain("from");
|
||||
const serialized = JSON.stringify(only(test.sent)).toLowerCase();
|
||||
for (const forbidden of ["smtp", "starttls", "password", "587", "465"]) {
|
||||
expect(serialized).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity password-reset email", () => {
|
||||
it("sends one message with a link on Better Auth's reset callback", async () => {
|
||||
const test = harness();
|
||||
|
||||
await test.notifications.sendResetPassword({
|
||||
token: "reset-token-xyz",
|
||||
user: { email: "[email protected]", name: "Sam" },
|
||||
});
|
||||
|
||||
const message = only(test.sent);
|
||||
expect(message.to).toEqual(["[email protected]"]);
|
||||
expect(message.subject).toBe("Reset your Ekklesia password");
|
||||
const link = (message.text ?? "").split("\n").find((line) => line.startsWith("https://")) ?? "";
|
||||
const url = new URL(link);
|
||||
expect(url.origin).toBe(authBaseUrl);
|
||||
expect(url.pathname).toBe("/api/auth/reset-password/reset-token-xyz");
|
||||
const callback = new URL(url.searchParams.get("callbackURL") ?? "");
|
||||
expect(callback.origin).toBe(dashboardOrigin);
|
||||
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||
});
|
||||
|
||||
it("carries no password, hash, or session material", async () => {
|
||||
const test = harness();
|
||||
|
||||
await test.notifications.sendResetPassword({
|
||||
token: "reset-token-xyz",
|
||||
user: { email: "[email protected]" },
|
||||
});
|
||||
|
||||
const serialized = bodies(only(test.sent)).toLowerCase();
|
||||
expect(serialized).not.toContain("hash");
|
||||
expect(serialized).not.toContain("session");
|
||||
// It does say the current password still works, which is the reassurance the flow needs.
|
||||
expect(serialized).toContain("current password stays in place");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity notification failures", () => {
|
||||
it("resolves rather than throwing when the relay fails, and reports a sanitized code", async () => {
|
||||
const test = harness({
|
||||
mail: {
|
||||
close: async () => undefined,
|
||||
send: async () => {
|
||||
throw new MailError("MAIL_PROVIDER_UNAVAILABLE");
|
||||
},
|
||||
verify: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolving is the security property: Better Auth's own `/send-verification-email` rethrows
|
||||
// whatever this throws, which would make "account exists" observable the moment SMTP broke.
|
||||
await expect(
|
||||
test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(test.failures).toEqual([
|
||||
{ kind: "email-verification", reason: "MAIL_PROVIDER_UNAVAILABLE" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses an unrecognized thrown value rather than forwarding it", async () => {
|
||||
const test = harness({
|
||||
mail: {
|
||||
close: async () => undefined,
|
||||
send: async () => {
|
||||
throw new Error("connect ECONNREFUSED 10.40.0.2:587 [email protected] pass=hunter2");
|
||||
},
|
||||
verify: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
|
||||
|
||||
expect(test.failures).toEqual([{ kind: "password-reset", reason: "MAIL_SEND_FAILED" }]);
|
||||
const serialized = JSON.stringify(test.failures);
|
||||
expect(serialized).not.toContain("hunter2");
|
||||
expect(serialized).not.toContain("10.40.0.2");
|
||||
});
|
||||
|
||||
it("reports MAIL_NOT_CONFIGURED, and still resolves, when the installation has no mail", async () => {
|
||||
const test = harness({ configured: false });
|
||||
|
||||
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
|
||||
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
|
||||
|
||||
expect(test.failures).toEqual([
|
||||
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
|
||||
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
|
||||
]);
|
||||
expect(test.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes one fixed line by default, carrying neither the address nor the token", async () => {
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
try {
|
||||
const notifications = createIdentityNotifications({
|
||||
authBaseUrl,
|
||||
dashboardOrigin,
|
||||
mail: undefined,
|
||||
});
|
||||
await notifications.sendResetPassword({
|
||||
token: "reset-token-xyz",
|
||||
user: { email: "[email protected]" },
|
||||
});
|
||||
|
||||
expect(errors).toHaveBeenCalledTimes(1);
|
||||
const line = String(errors.mock.calls[0]?.[0]);
|
||||
expect(line).toBe("[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED");
|
||||
expect(line).not.toContain("secret-person");
|
||||
expect(line).not.toContain("reset-token-xyz");
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity notification message safety", () => {
|
||||
it("escapes a display name rather than letting it close an element", async () => {
|
||||
const test = harness();
|
||||
|
||||
await test.notifications.sendVerificationEmail({
|
||||
token,
|
||||
user: { email: "[email protected]", name: 'Sam"><script>alert(1)</script>' },
|
||||
});
|
||||
|
||||
const message = only(test.sent);
|
||||
expect(message.html).not.toContain("<script>");
|
||||
expect(message.html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("drops an unusable display name instead of failing the whole message", async () => {
|
||||
const test = harness();
|
||||
|
||||
// A stored name carrying a control character would be refused by the message validator, which
|
||||
// would turn a cosmetic problem into an undeliverable password reset.
|
||||
await test.notifications.sendResetPassword({
|
||||
token: "t",
|
||||
user: { email: "[email protected]", name: "Sam\r\nBcc: [email protected]" },
|
||||
});
|
||||
|
||||
const message = only(test.sent);
|
||||
expect(message.text).toContain("Hello,");
|
||||
expect(message.text).not.toContain("[email protected]");
|
||||
expect(test.failures).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses to send to an address the mail contract rejects, without throwing", async () => {
|
||||
const test = harness();
|
||||
|
||||
await test.notifications.sendVerificationEmail({
|
||||
token,
|
||||
user: { email: "[email protected], [email protected]" },
|
||||
});
|
||||
|
||||
expect(test.sent).toHaveLength(0);
|
||||
expect(test.failures).toEqual([
|
||||
{ kind: "email-verification", reason: "MAIL_RECIPIENT_INVALID" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { identityAdapterSchema } from "../src/identity/schema.js";
|
||||
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
|
||||
return Object.freeze({
|
||||
baseUrl: "https://identity.example.test",
|
||||
dashboardOrigin: "https://identity.example.test",
|
||||
publicSignupEnabled: false,
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||
@@ -109,18 +110,21 @@ describe("Core identity runtime construction boundary", () => {
|
||||
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
|
||||
});
|
||||
|
||||
it("enables exactly email/password (autoSignIn disabled) and rate limiting, nothing else", () => {
|
||||
it("enables exactly email/password, verification, and rate limiting, nothing else", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
// Exhaustive on purpose: a new top-level Better Auth feature has to be added here deliberately,
|
||||
// never by inheriting a default.
|
||||
expect(Object.keys(options).sort()).toEqual([
|
||||
"advanced",
|
||||
"baseURL",
|
||||
"database",
|
||||
"emailAndPassword",
|
||||
"emailVerification",
|
||||
"logger",
|
||||
"onAPIError",
|
||||
"rateLimit",
|
||||
@@ -133,13 +137,60 @@ describe("Core identity runtime construction boundary", () => {
|
||||
enabled: true,
|
||||
maxPasswordLength: 128,
|
||||
minPasswordLength: 8,
|
||||
// A verified address is a precondition for holding a session (ADR 0038).
|
||||
requireEmailVerification: true,
|
||||
resetPasswordTokenExpiresIn: 3600,
|
||||
// Password reset revokes that human's other sessions, so recovering from a compromise is not
|
||||
// cosmetic.
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
});
|
||||
expect(options.emailVerification).toEqual({
|
||||
// Following an emailed link never mints a session: mail infrastructure dereferences links.
|
||||
autoSignInAfterVerification: false,
|
||||
expiresIn: 3600,
|
||||
sendOnSignIn: true,
|
||||
sendOnSignUp: true,
|
||||
});
|
||||
// No `customRules`: production inherits Better Auth's own stricter per-path defaults.
|
||||
expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
|
||||
expect(options.baseURL).toBe(validConfig().baseUrl);
|
||||
expect(options.secret).toBe(validConfig().secret);
|
||||
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
||||
});
|
||||
|
||||
it("wires the injected notifier into both delivery callbacks, and neither without it", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const sendResetPassword = vi.fn(async () => undefined);
|
||||
const sendVerificationEmail = vi.fn(async () => undefined);
|
||||
|
||||
createIdentityRuntime({
|
||||
config: validConfig(),
|
||||
db: fakeDb(),
|
||||
notifications: { sendResetPassword, sendVerificationEmail },
|
||||
});
|
||||
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect((options.emailAndPassword as Record<string, unknown>).sendResetPassword).toBe(
|
||||
sendResetPassword,
|
||||
);
|
||||
expect((options.emailVerification as Record<string, unknown>).sendVerificationEmail).toBe(
|
||||
sendVerificationEmail,
|
||||
);
|
||||
|
||||
betterAuthMock.mockClear();
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
const [withoutMail] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
// An installation with no mail configures the identical policy and simply cannot deliver.
|
||||
// Crucially it does **not** relax `requireEmailVerification`.
|
||||
expect(withoutMail.emailAndPassword).toMatchObject({ requireEmailVerification: true });
|
||||
expect(
|
||||
(withoutMail.emailAndPassword as Record<string, unknown>).sendResetPassword,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(withoutMail.emailVerification as Record<string, unknown>).sendVerificationEmail,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
@@ -254,7 +305,7 @@ describe("Core identity runtime construction boundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("only widens the sign-in/sign-up rate-limit window when a narrow override is explicitly injected", () => {
|
||||
it("only widens the four strict rate-limit windows when a narrow override is explicitly injected", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({
|
||||
@@ -266,6 +317,10 @@ describe("Core identity runtime construction boundary", () => {
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(options.rateLimit).toEqual({
|
||||
customRules: {
|
||||
// The two paths that trigger an email are included, so a test suite driving many recovery
|
||||
// requests is not throttled by Better Auth's 3-per-60-seconds default for them.
|
||||
"/request-password-reset": { max: 1000, window: 1 },
|
||||
"/send-verification-email": { max: 1000, window: 1 },
|
||||
"/sign-in/email": { max: 1000, window: 1 },
|
||||
"/sign-up/email": { max: 1000, window: 1 },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Structural guarantees about email verification and password recovery that no runtime assertion can
|
||||
* make (ADR 0038).
|
||||
*
|
||||
* These read the repository itself, because what matters here is what code *exists* and what depends
|
||||
* on what: that identity never learns an SMTP detail, that no token store was reimplemented
|
||||
* alongside Better Auth's, that a device identity has no route into a human email flow, and that
|
||||
* nothing in a browser bundle can reach any of it.
|
||||
*/
|
||||
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
async function readTypeScriptFiles(
|
||||
directory: string,
|
||||
): Promise<ReadonlyArray<{ readonly path: string; readonly text: string }>> {
|
||||
const results: Array<{ path: string; text: string }> = [];
|
||||
const walk = async (current: string): Promise<void> => {
|
||||
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") {
|
||||
continue;
|
||||
}
|
||||
await walk(full);
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||
results.push({ path: full, text: await readFile(full, "utf8") });
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(path.join(repositoryRoot, directory));
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Strips block comments and whole-line `//` comments: these assertions are about code, and prose
|
||||
* explaining a rule inevitably quotes the token the rule forbids. */
|
||||
function withoutComments(text: string): string {
|
||||
return text
|
||||
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
|
||||
.split("\n")
|
||||
.filter((line) => !line.trimStart().startsWith("//"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe("identity email flows stay above the mail transport", () => {
|
||||
it("names no SMTP or provider detail anywhere in the identity feature", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
|
||||
const code = withoutComments(file.text).toLowerCase();
|
||||
for (const forbidden of [
|
||||
"nodemailer",
|
||||
"smtpserver",
|
||||
"starttls",
|
||||
"createtransport",
|
||||
"587",
|
||||
"465",
|
||||
]) {
|
||||
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reaches the transport only through the provider seam", async () => {
|
||||
const notifications = await readFile(
|
||||
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The whole mail surface identity may use: a validated message and a `send`. Not a config, not
|
||||
// a transport, not a credential.
|
||||
expect(notifications).toContain('from "../mail/index.js"');
|
||||
expect(withoutComments(notifications)).not.toContain("MailProviderConfig");
|
||||
expect(withoutComments(notifications)).not.toContain("createSmtpMailProvider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Better Auth owns every token", () => {
|
||||
it("adds no Ekklesia table, column, or store for a verification or reset token", async () => {
|
||||
const schemas = [
|
||||
path.join("core", "src", "identity", "schema.ts"),
|
||||
path.join("core", "src", "authorization", "schema.ts"),
|
||||
path.join("core", "src", "devices", "schema.ts"),
|
||||
];
|
||||
|
||||
for (const relative of schemas) {
|
||||
const code = withoutComments(
|
||||
await readFile(path.join(repositoryRoot, relative), "utf8"),
|
||||
).toLowerCase();
|
||||
// A parallel token table would be a second expiry clock, a second consumption rule, and a
|
||||
// second thing to get wrong. `ekklesia_core_verification` is Better Auth's own table.
|
||||
expect(code, relative).not.toContain("reset_token");
|
||||
expect(code, relative).not.toContain("verification_token");
|
||||
expect(code, relative).not.toContain("password_reset");
|
||||
}
|
||||
});
|
||||
|
||||
it("never hashes, stores, or compares a token itself", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
|
||||
const code = withoutComments(file.text);
|
||||
expect(code, file.path).not.toContain("createHash");
|
||||
expect(code, file.path).not.toContain("timingSafeEqual");
|
||||
}
|
||||
});
|
||||
|
||||
it("interpolates no token, link, address, or message body into anything it logs", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
|
||||
const code = withoutComments(file.text);
|
||||
for (const call of code.matchAll(/console\.\w+\(([^\n]*)/gu)) {
|
||||
// Fixed prose in a log line is fine; a **substituted value** is what could carry a secret.
|
||||
// Only the interpolations are examined, so "email was not delivered" passes while
|
||||
// `${user.email}` could not.
|
||||
for (const substitution of (call[1] ?? "").matchAll(/\$\{([^}]*)\}/gu)) {
|
||||
const expression = (substitution[1] ?? "").toLowerCase();
|
||||
for (const forbidden of ["token", "url", "html", "text", "email", "recipient", "body"]) {
|
||||
expect(expression, `${file.path} logs ${forbidden}`).not.toContain(forbidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed devices have no part in human email identity", () => {
|
||||
it("gives a device no address, password, or verification state", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("core", "src", "devices"))) {
|
||||
const code = withoutComments(file.text).toLowerCase();
|
||||
for (const forbidden of ["email", "password", "emailverified", "sendresetpassword"]) {
|
||||
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the device store out of the identity notification path entirely", async () => {
|
||||
const notifications = await readFile(
|
||||
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(notifications).not.toContain("devices");
|
||||
expect(notifications).not.toContain("Device");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the browser shell reaches none of it", () => {
|
||||
it("imports neither Better Auth nor Core's mail from any browser source", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||
expect(file.text, file.path).not.toContain("better-auth");
|
||||
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
|
||||
expect(file.text, file.path).not.toContain("nodemailer");
|
||||
}
|
||||
});
|
||||
|
||||
it("declares no server identity or mail dependency in the shell package", async () => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(repositoryRoot, "surfaces", "shell", "package.json"), "utf8"),
|
||||
) as Record<string, Record<string, string> | undefined>;
|
||||
const declared = Object.keys({
|
||||
...(manifest.dependencies ?? {}),
|
||||
...(manifest.devDependencies ?? {}),
|
||||
});
|
||||
|
||||
expect(declared).not.toContain("better-auth");
|
||||
expect(declared).not.toContain("nodemailer");
|
||||
expect(declared).not.toContain("@ekklesia/core");
|
||||
});
|
||||
|
||||
it("never puts a reset token in browser storage", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||
const code = withoutComments(file.text);
|
||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
||||
// the life of one form and nowhere else.
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
expect(code, file.path).not.toContain("sessionStorage");
|
||||
expect(code, file.path).not.toContain("document.cookie");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("public signup stays gone", () => {
|
||||
it("offers no registration path from any browser source", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||
const code = withoutComments(file.text);
|
||||
// Email verification is not a licence to self-register (ADR 0038).
|
||||
expect(code, file.path).not.toContain("/sign-up");
|
||||
expect(code, file.path).not.toContain("signUp");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,30 +42,42 @@ async function readTextFiles(directory: string): Promise<Array<{ path: string; t
|
||||
}
|
||||
|
||||
describe("outbound email stays server-only", () => {
|
||||
it("declares the SMTP dependency in Core alone", async () => {
|
||||
it("declares the SMTP transport in Core alone", async () => {
|
||||
const core = await readJson("core/package.json");
|
||||
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
|
||||
|
||||
const mailPackages = (names: readonly string[]): readonly string[] =>
|
||||
names.filter((name) => /nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name));
|
||||
|
||||
// Every package a browser can end up executing, plus the contracts barrel a surface must not
|
||||
// reach. None may carry a mail transport.
|
||||
// reach. None may carry anything mail-related at all, in either dependency list.
|
||||
for (const manifest of [
|
||||
"packages/contracts/package.json",
|
||||
"packages/module-sdk/package.json",
|
||||
"packages/module-ui-sdk/package.json",
|
||||
"packages/module-ui-host/package.json",
|
||||
"surfaces/shell/package.json",
|
||||
"apps/api/package.json",
|
||||
]) {
|
||||
const parsed = await readJson(manifest);
|
||||
const dependencies = {
|
||||
...((parsed.dependencies ?? {}) as Record<string, string>),
|
||||
...((parsed.devDependencies ?? {}) as Record<string, string>),
|
||||
};
|
||||
const mailDependencies = Object.keys(dependencies).filter((name) =>
|
||||
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name),
|
||||
);
|
||||
expect(mailDependencies, manifest).toEqual([]);
|
||||
expect(
|
||||
mailPackages([
|
||||
...Object.keys((parsed.dependencies ?? {}) as object),
|
||||
...Object.keys((parsed.devDependencies ?? {}) as object),
|
||||
]),
|
||||
manifest,
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
// The API executable ships no mail package of any kind: it reaches SMTP only through Core's
|
||||
// provider. Its *test* dependencies may include `smtp-server`, which is a receiving server and
|
||||
// has no capability to send anything — that is what lets the verification and recovery
|
||||
// integration suites capture a real message instead of asserting against a fake (ADR 0038).
|
||||
const api = await readJson("apps/api/package.json");
|
||||
expect(mailPackages(Object.keys((api.dependencies ?? {}) as object))).toEqual([]);
|
||||
expect(mailPackages(Object.keys((api.devDependencies ?? {}) as object))).toEqual([
|
||||
"@types/smtp-server",
|
||||
"smtp-server",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never imports the transport outside Core's own mail module", async () => {
|
||||
@@ -149,15 +161,44 @@ describe("outbound email stays server-only", () => {
|
||||
});
|
||||
|
||||
it("hardcodes no provider hostname in Core", async () => {
|
||||
// Google Workspace is a deployment target, not the architecture.
|
||||
// Google Workspace is a deployment target, not the architecture. It is the deployment that
|
||||
// exposed the generic `EHLO` defect the HELO-name setting fixes, and it is named nowhere the
|
||||
// code could come to depend on it.
|
||||
for (const file of await readTextFiles("core/src/mail")) {
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("gmail");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("smtp-relay");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("resend");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("brevo");
|
||||
const text = file.text.toLowerCase();
|
||||
for (const forbidden of [
|
||||
"gmail",
|
||||
"smtp-relay",
|
||||
"googlemail",
|
||||
"google.com",
|
||||
"outlook",
|
||||
"office365",
|
||||
"sendgrid",
|
||||
"mailgun",
|
||||
"postmark",
|
||||
"resend",
|
||||
"brevo",
|
||||
]) {
|
||||
expect(text, `${file.path} names ${forbidden}`).not.toContain(forbidden);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the SMTP client identity to nothing, so no deployment's name is baked in", async () => {
|
||||
const config = await readFile(path.join(repositoryRoot, "core/src/mail/config.ts"), "utf8");
|
||||
const provider = await readFile(
|
||||
path.join(repositoryRoot, "core/src/mail/smtp-provider.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The value that fixed a real deployment is configuration, never a constant. A fallback literal
|
||||
// here would be one church's domain compiled into every installation.
|
||||
expect(config).not.toContain("riverofglorymn");
|
||||
expect(provider).not.toContain("riverofglorymn");
|
||||
// The provider passes it through and supplies nothing of its own when it is absent.
|
||||
expect(provider).toContain("config.heloName === undefined ? {} : { name: config.heloName }");
|
||||
});
|
||||
|
||||
it("never disables TLS certificate verification", async () => {
|
||||
for (const file of await readTextFiles("core/src/mail")) {
|
||||
expect(file.text, file.path).not.toContain("rejectUnauthorized");
|
||||
|
||||
@@ -193,6 +193,116 @@ describe("host mail configuration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SMTP client identity (HELO name)", () => {
|
||||
/**
|
||||
* A real deployment defect, and a generic one.
|
||||
*
|
||||
* An SMTP client must greet the server with its own identity. Nodemailer derives that from the
|
||||
* machine hostname and falls back to the address literal `[127.0.0.1]` when the hostname is not
|
||||
* fully qualified — the normal state of a container. Some relays close the connection on that
|
||||
* greeting with a `421` before any message is offered, which reaches a caller as an
|
||||
* indistinguishable `MAIL_PROVIDER_UNAVAILABLE`. The deployment can now say what to greet as.
|
||||
*/
|
||||
it("omits the setting entirely when the deployment configures none", () => {
|
||||
const config = createMailProviderConfig(validConfig);
|
||||
|
||||
// Absent means Nodemailer's own derivation, unchanged. The key is not present at all rather
|
||||
// than present-and-undefined, so nothing downstream can mistake it for a configured empty name.
|
||||
expect(config).not.toHaveProperty("heloName");
|
||||
expect(createMailProviderConfig({ ...validConfig, heloName: "" })).not.toHaveProperty(
|
||||
"heloName",
|
||||
);
|
||||
expect(createMailProviderConfig({ ...validConfig, heloName: undefined })).not.toHaveProperty(
|
||||
"heloName",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a two-label domain", "example.org"],
|
||||
["a subdomain", "mail.example.org"],
|
||||
["a deep subdomain", "smtp.relay.mail.example.org"],
|
||||
["hyphens inside labels", "out-bound.example-church.org"],
|
||||
["digits", "mx1.example2.org"],
|
||||
["mixed case", "Mail.Example.ORG"],
|
||||
])("accepts %s", (_label, heloName) => {
|
||||
expect(createMailProviderConfig({ ...validConfig, heloName })).toMatchObject({ heloName });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a scheme", "smtp://example.org"],
|
||||
["a port", "example.org:587"],
|
||||
["a path", "example.org/relay"],
|
||||
["a space", "example org"],
|
||||
["a leading space", " example.org"],
|
||||
["a trailing space", "example.org "],
|
||||
["a carriage return", "example.org\r"],
|
||||
["a line feed", "example.org\nEHLO evil.example.net"],
|
||||
["a tab", "example.org\t"],
|
||||
["a comma", "example.org,evil.example.net"],
|
||||
["an unqualified single label", "localhost"],
|
||||
["a machine name with no domain", "ekklesia-host"],
|
||||
["a bracketed address literal", "[127.0.0.1]"],
|
||||
["a bare IPv4 address", "192.0.2.1"],
|
||||
["a trailing dot", "example.org."],
|
||||
["a leading dot", ".example.org"],
|
||||
["consecutive dots", "example..org"],
|
||||
["a leading hyphen in a label", "-example.org"],
|
||||
["a trailing hyphen in a label", "example-.org"],
|
||||
["an email address", "[email protected]"],
|
||||
["prose", "our church mail server"],
|
||||
["nothing but dots", "..."],
|
||||
["an over-long name", `${"a".repeat(250)}.example.org`],
|
||||
])("refuses %s", (_label, heloName) => {
|
||||
expect(() => createMailProviderConfig({ ...validConfig, heloName })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_HELO_NAME_INVALID" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an unqualified name, because that is the input the fallback already rejects", () => {
|
||||
// Configuring a single label would reproduce the very failure this setting exists to fix:
|
||||
// Nodemailer discards a non-FQDN hostname and substitutes the address literal.
|
||||
expect(() => createMailProviderConfig({ ...validConfig, heloName: "ekklesia" })).toThrow(
|
||||
MailConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
it("never carries the offending value in the error", () => {
|
||||
try {
|
||||
createMailProviderConfig({ ...validConfig, heloName: "smtp://secret-internal-name:2525" });
|
||||
throw new Error("expected a refusal");
|
||||
} catch (error) {
|
||||
const serialized = `${(error as Error).message} ${(error as Error).stack ?? ""}`;
|
||||
expect(serialized).not.toContain("secret-internal-name");
|
||||
expect(serialized).not.toContain("2525");
|
||||
}
|
||||
});
|
||||
|
||||
it("is described to an operator, because it is infrastructure rather than a credential", () => {
|
||||
const described = describeMailProvider(
|
||||
createMailProviderConfig({
|
||||
...validConfig,
|
||||
heloName: "mail.example.org",
|
||||
password,
|
||||
username: "[email protected]",
|
||||
}) as Parameters<typeof describeMailProvider>[0],
|
||||
);
|
||||
|
||||
// It is announced in the clear on every connection, so printing it leaks nothing — and it is
|
||||
// exactly what an operator needs when a relay has refused the greeting.
|
||||
expect(described).toContain("HELO mail.example.org");
|
||||
expect(described).not.toContain(password);
|
||||
expect(described).not.toContain("[email protected]");
|
||||
});
|
||||
|
||||
it("says nothing about a HELO name when none is configured", () => {
|
||||
const described = describeMailProvider(
|
||||
createMailProviderConfig(validConfig) as Parameters<typeof describeMailProvider>[0],
|
||||
);
|
||||
|
||||
expect(described).not.toContain("HELO");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transactional message validation", () => {
|
||||
const message = {
|
||||
subject: "Verify your email address",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { AddressInfo, Socket } from "node:net";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { SMTPServer } from "smtp-server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -25,6 +25,8 @@ interface CapturedMessage {
|
||||
readonly from: string;
|
||||
readonly to: readonly string[];
|
||||
readonly data: string;
|
||||
/** The argument this client actually sent in `EHLO`, lowercased by the server. */
|
||||
readonly greetedAs: string;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
@@ -52,6 +54,14 @@ interface HarnessOptions {
|
||||
readonly rejectRecipients?: readonly string[];
|
||||
/** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */
|
||||
readonly offerStartTls?: boolean;
|
||||
/**
|
||||
* Refuses any client whose `EHLO` argument is not exactly this name, as a relay that requires a
|
||||
* fully qualified client identity does.
|
||||
*
|
||||
* The refusal is a `421`, the same class of response the real relay returned: a temporary failure
|
||||
* that closes the connection before a message is offered.
|
||||
*/
|
||||
readonly requireGreeting?: string;
|
||||
}
|
||||
|
||||
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
||||
@@ -81,11 +91,26 @@ async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
||||
captured.push({
|
||||
data,
|
||||
from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address,
|
||||
greetedAs: session.hostNameAppearsAs,
|
||||
to: session.envelope.rcptTo.map((entry) => entry.address),
|
||||
});
|
||||
callback();
|
||||
});
|
||||
},
|
||||
onMailFrom(_address, session, callback) {
|
||||
if (
|
||||
options.requireGreeting !== undefined &&
|
||||
session.hostNameAppearsAs !== options.requireGreeting.toLowerCase()
|
||||
) {
|
||||
const error = new Error("Try again later, closing connection.") as Error & {
|
||||
responseCode?: number;
|
||||
};
|
||||
error.responseCode = 421;
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
onRcptTo(address, _session, callback) {
|
||||
if (options.rejectRecipients?.includes(address.address)) {
|
||||
// 550 is a permanent refusal of this mailbox, which is what a real relay returns.
|
||||
@@ -107,6 +132,64 @@ async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
||||
return { captured, close, port: address.port };
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay that refuses the greeting itself, byte for byte as the real one did.
|
||||
*
|
||||
* `smtp-server` exposes no `EHLO` hook — its earliest usable one is `MAIL FROM`, which
|
||||
* `transporter.verify()` never reaches — so modelling this failure at the point it actually happens
|
||||
* needs a socket that speaks the handful of SMTP verbs involved. That matters: the whole reason the
|
||||
* defect was hard to diagnose is that it aborts the session *before* a message is offered, which is
|
||||
* also why Doctor's send-nothing probe can see it.
|
||||
*/
|
||||
async function startGreetingStrictRelay(expected: string): Promise<Harness> {
|
||||
const sockets = new Set<Socket>();
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => undefined);
|
||||
socket.write("220 relay.invalid ESMTP\r\n");
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
let index = buffer.indexOf("\r\n");
|
||||
while (index !== -1) {
|
||||
const line = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 2);
|
||||
const [verb = "", argument = ""] = line.split(" ");
|
||||
if (verb.toUpperCase() === "EHLO" || verb.toUpperCase() === "HELO") {
|
||||
if (argument.toLowerCase() !== expected.toLowerCase()) {
|
||||
// The exact shape the deployment saw: a 421 naming no reason, then the door shut.
|
||||
socket.write("421-4.7.0 Try again later, closing connection. (EHLO)\r\n");
|
||||
socket.write("421 4.7.0 Goodbye\r\n");
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
socket.write("250-relay.invalid\r\n250 OK\r\n");
|
||||
} else if (verb.toUpperCase() === "QUIT") {
|
||||
socket.write("221 Bye\r\n");
|
||||
socket.end();
|
||||
return;
|
||||
} else {
|
||||
socket.write("250 OK\r\n");
|
||||
}
|
||||
index = buffer.indexOf("\r\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
const close = async (): Promise<void> => {
|
||||
// `close()` waits for open sockets, and a refused client may still be holding one.
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
};
|
||||
openHarnesses.push(close);
|
||||
return { captured: [], close, port: address.port };
|
||||
}
|
||||
|
||||
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
|
||||
const built = createMailProviderConfig(
|
||||
{
|
||||
@@ -355,3 +438,94 @@ describe.sequential("live SMTP transport", () => {
|
||||
expect(harness.captured).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SMTP client identity on the wire", () => {
|
||||
/**
|
||||
* The regression guard for a real production defect.
|
||||
*
|
||||
* A deployment relaying through an IP-authorized relay could not send at all: every attempt
|
||||
* surfaced as `MAIL_PROVIDER_UNAVAILABLE`. The relay was healthy — `swaks` sent from the same
|
||||
* machine — and the cause was the greeting. An SMTP client identifies itself in `EHLO`, 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. The relay closed the
|
||||
* connection on it with a `421`.
|
||||
*
|
||||
* These tests are written against the wire rather than against the transport options, because the
|
||||
* defect was invisible in the options: everything about the configuration looked correct.
|
||||
*/
|
||||
it("greets with the configured name, exactly as configured", async () => {
|
||||
const harness = await startSmtpServer();
|
||||
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||
|
||||
await mail.send(
|
||||
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
|
||||
);
|
||||
|
||||
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
|
||||
});
|
||||
|
||||
it("never greets as the address literal when a name is configured", async () => {
|
||||
const harness = await startSmtpServer();
|
||||
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||
|
||||
await mail.send(
|
||||
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
|
||||
);
|
||||
|
||||
// The exact greeting the relay refused.
|
||||
expect(harness.captured[0]?.greetedAs).not.toBe("[127.0.0.1]");
|
||||
});
|
||||
|
||||
it("delivers to a relay that demands a fully qualified greeting", async () => {
|
||||
const harness = await startSmtpServer({ requireGreeting: "mail.example.org" });
|
||||
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||
|
||||
await mail.send(
|
||||
validateMailMessage({ subject: "Accepted", text: "Body.", to: "[email protected]" }),
|
||||
);
|
||||
|
||||
expect(harness.captured).toHaveLength(1);
|
||||
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
|
||||
});
|
||||
|
||||
it("is refused by that same relay when no name is configured", async () => {
|
||||
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||
const mail = provider(config(harness.port));
|
||||
|
||||
// Deterministic regardless of what this machine is called: the server accepts exactly one name,
|
||||
// and Nodemailer's derivation cannot produce `mail.example.org` on any host.
|
||||
await expect(
|
||||
mail.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" })),
|
||||
).rejects.toMatchObject({ code: "MAIL_PROVIDER_UNAVAILABLE" });
|
||||
expect(harness.captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("carries the refused greeting into no error a caller can see", async () => {
|
||||
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||
const mail = provider(config(harness.port));
|
||||
|
||||
const failure = await mail
|
||||
.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" }))
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
// A `421` is SMTP's "try later", so it stays `MAIL_PROVIDER_UNAVAILABLE` rather than becoming a
|
||||
// permanent rejection — and the server's response text never reaches the caller.
|
||||
const serialized = `${(failure as Error).message} ${(failure as Error).stack ?? ""}`;
|
||||
expect(serialized).not.toContain("Try again later");
|
||||
expect(serialized).not.toContain("closing connection");
|
||||
});
|
||||
|
||||
it("still verifies through the same greeting Doctor's probe will use", async () => {
|
||||
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||
const configured = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||
const unconfigured = provider(config(harness.port));
|
||||
|
||||
// Doctor builds its provider from the identical validated configuration, so an operator whose
|
||||
// relay refuses the greeting sees it in `pnpm doctor` rather than only when a reset email
|
||||
// silently fails to arrive.
|
||||
await expect(configured.verify()).resolves.toBeUndefined();
|
||||
await expect(unconfigured.verify()).rejects.toMatchObject({
|
||||
code: "MAIL_PROVIDER_UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -27,6 +27,7 @@ function fakeModule(
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"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 {
|
||||
@@ -193,7 +194,7 @@ afterAll(async () => {
|
||||
|
||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -21,7 +21,7 @@ function manifestWith(
|
||||
id = "announcements",
|
||||
) {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
||||
],
|
||||
])("rejects %s", (_label, declaration) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
||||
"valueOf",
|
||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
||||
"",
|
||||
])("rejects the malformed configuration key %s", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -957,3 +957,179 @@ describe("module discovery", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A contribution artifact is resolved through the identical loop as every other compiled entrypoint
|
||||
* (ADR 0041), so it inherits every protection `entrypoints.ui` already had rather than receiving a
|
||||
* second, weaker implementation of them.
|
||||
*/
|
||||
describe("browser contribution artifact resolution", () => {
|
||||
function contributingManifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
...manifestFor("announcements"),
|
||||
contributions: [
|
||||
{
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: "./dist/contributions/signage-slide/index.js",
|
||||
...overrides,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("resolves a declared contribution to a canonical path inside the package", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "announcements", {
|
||||
extraFiles: { "dist/contributions/signage-slide/index.js": "export default {};" },
|
||||
manifest: contributingManifest(),
|
||||
});
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const contribution = modules[0]?.contributions[0];
|
||||
expect(contribution).toMatchObject({
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
});
|
||||
expect(contribution?.artifactPath.endsWith("dist/contributions/signage-slide/index.js")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(path.isAbsolute(contribution?.artifactPath ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an empty contribution list for a module that contributes nothing", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "calendar");
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(modules[0]?.contributions).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing contribution artifact", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "announcements", { manifest: contributingManifest() });
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: "MODULE_ENTRYPOINT_MISSING" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a contribution artifact that is not compiled JavaScript", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "announcements", {
|
||||
extraFiles: { "src/contributions/signage-slide/index.tsx": "export default {};" },
|
||||
manifest: contributingManifest({ artifact: "./src/contributions/signage-slide/index.tsx" }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a contribution artifact that escapes the package through a link", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const outside = await createModulesDirectory();
|
||||
await writeFile(path.join(outside, "elsewhere.js"), "export default {};");
|
||||
await createModule(modulesDirectory, "announcements", {
|
||||
manifest: contributingManifest(),
|
||||
});
|
||||
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/signage-slide"), {
|
||||
recursive: true,
|
||||
});
|
||||
await symlink(
|
||||
path.join(outside, "elsewhere.js"),
|
||||
path.join(modulesDirectory, "announcements/dist/contributions/signage-slide/index.js"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
path: "contributions.0.artifact",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects two contributions that resolve through a link to the same file", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const manifest = {
|
||||
...manifestFor("announcements"),
|
||||
contributions: [
|
||||
{
|
||||
id: "announcements.first",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: "./dist/contributions/first/index.js",
|
||||
},
|
||||
{
|
||||
id: "announcements.second",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: "./dist/contributions/second/index.js",
|
||||
},
|
||||
],
|
||||
};
|
||||
await createModule(modulesDirectory, "announcements", {
|
||||
extraFiles: { "dist/contributions/first/index.js": "export default {};" },
|
||||
manifest,
|
||||
});
|
||||
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/second"), {
|
||||
recursive: true,
|
||||
});
|
||||
await symlink(
|
||||
path.join(modulesDirectory, "announcements/dist/contributions/first/index.js"),
|
||||
path.join(modulesDirectory, "announcements/dist/contributions/second/index.js"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID", path: "contributions" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,6 +181,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"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 {
|
||||
@@ -199,7 +200,7 @@ afterAll(async () => {
|
||||
* by eligibility resolution. */
|
||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -208,6 +209,7 @@ function fakeModule(id: string, dependencies?: Record<string, unknown>): Discove
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -22,7 +22,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -36,6 +36,7 @@ function fakeModule(
|
||||
},
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -65,7 +65,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -28,7 +28,7 @@ interface PermissionInput {
|
||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
|
||||
},
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -31,7 +31,7 @@ function manifest(
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -57,7 +57,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -861,6 +861,7 @@ describe("module HTTP routes", () => {
|
||||
expect(runtime.routes).toHaveLength(3);
|
||||
for (const route of runtime.routes) {
|
||||
expect(Object.keys(route).sort()).toEqual([
|
||||
"bodyMode",
|
||||
"handler",
|
||||
"maxBodyBytes",
|
||||
"method",
|
||||
|
||||
@@ -41,7 +41,7 @@ function manifestFor(
|
||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -29,6 +29,7 @@ function fakeModule(
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -29,6 +29,15 @@ interface ModuleFixture {
|
||||
readonly ui?: string;
|
||||
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
||||
readonly display?: string;
|
||||
/** Capabilities this module provides — what another module's contributions can target. */
|
||||
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||
/** Browser renderers this module contributes to another module's capability. */
|
||||
readonly contributions?: readonly {
|
||||
readonly localName: string;
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
@@ -61,8 +70,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
},
|
||||
}),
|
||||
},
|
||||
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: contribution.version,
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
@@ -89,6 +106,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||
}
|
||||
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 {};");
|
||||
}
|
||||
}
|
||||
|
||||
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
|
||||
@@ -294,3 +320,128 @@ describe("module display UI publication registry", () => {
|
||||
expect(registry.listDisplays()).toBe(registry.listDisplays());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The generic contribution mechanism (ADR 0041), exercised through the exact relationship it exists
|
||||
* for and with no platform knowledge of what any of it means: `signage` provides a capability,
|
||||
* `announcements` contributes a renderer to it, and neither module imports the other.
|
||||
*/
|
||||
describe("module contribution publication", () => {
|
||||
const signage = {
|
||||
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||
id: "signage",
|
||||
};
|
||||
const contributor = (capabilityRange: string) => ({
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange,
|
||||
localName: "signage-slide",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
id: "announcements",
|
||||
});
|
||||
|
||||
it("publishes a contribution whose target capability is provided within range", async () => {
|
||||
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(registry.listContributions()).toHaveLength(1);
|
||||
expect(
|
||||
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
||||
).toMatchObject({
|
||||
capability: "signage.slide-renderer",
|
||||
// The provider's declared version, so the range was checked against something real.
|
||||
capabilityVersion: "1.2.0",
|
||||
contributionId: "announcements.signage-slide",
|
||||
moduleId: "announcements",
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("serves exactly the canonical artifact path discovery validated", async () => {
|
||||
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||
const announcements = modules.find((module) => module.manifest.id === "announcements");
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
const published = registry.resolveContribution(
|
||||
"announcements",
|
||||
"0.1.0",
|
||||
"announcements.signage-slide",
|
||||
);
|
||||
expect(published?.artifactPath).toBe(announcements?.contributions[0]?.artifactPath);
|
||||
expect(path.isAbsolute(published?.artifactPath ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
it("withholds a contribution whose capability no installed module provides", async () => {
|
||||
const modules = await discover([contributor("^1.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(registry.listContributions()).toEqual([]);
|
||||
expect(
|
||||
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("withholds a contribution whose provider is outside its declared compatible range", async () => {
|
||||
// Fails closed: an incompatible renderer is absent rather than mounted and broken.
|
||||
const modules = await discover([signage, contributor("^2.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(registry.listContributions()).toEqual([]);
|
||||
expect(registry.contributionsForModule("signage")).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives a consuming module exactly the contributions aimed at its own capabilities", async () => {
|
||||
const modules = await discover([
|
||||
signage,
|
||||
{ capabilities: [{ id: "calendar.week-view", version: "1.0.0" }], id: "calendar" },
|
||||
contributor("^1.0.0"),
|
||||
]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(registry.contributionsForModule("signage").map((entry) => entry.contributionId)).toEqual(
|
||||
["announcements.signage-slide"],
|
||||
);
|
||||
// A module that provides a different capability sees nothing, and the *contributing* module
|
||||
// does not see its own contribution: it provides no capability it was aimed at.
|
||||
expect(registry.contributionsForModule("calendar")).toEqual([]);
|
||||
expect(registry.contributionsForModule("announcements")).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails a lookup closed for an unknown, mismatched, or non-string identity", async () => {
|
||||
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(
|
||||
registry.resolveContribution("announcements", "9.9.9", "announcements.signage-slide"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
registry.resolveContribution("signage", "0.1.0", "announcements.signage-slide"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
registry.resolveContribution("announcements", "0.1.0", "announcements.nope"),
|
||||
).toBeUndefined();
|
||||
expect(registry.resolveContribution(7, "0.1.0", "announcements.signage-slide")).toBeUndefined();
|
||||
expect(registry.contributionsForCapability(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no domain vocabulary at all", async () => {
|
||||
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
const published = registry.listContributions()[0];
|
||||
|
||||
// Core knows identity, version, compatibility, and one path. There is deliberately no payload
|
||||
// schema, slot, ordering, or rendering hint — what a renderer draws is not the platform's.
|
||||
expect(Object.keys(published ?? {}).sort()).toEqual([
|
||||
"artifactPath",
|
||||
"capability",
|
||||
"capabilityVersion",
|
||||
"contributionId",
|
||||
"moduleId",
|
||||
"moduleVersion",
|
||||
"version",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,15 @@ This is deliberately the smallest viable styling story. A real stylesheet contra
|
||||
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
|
||||
them here would expand the manifest and runtime contract before a host can validate them.
|
||||
|
||||
> **Correction — this rule is superseded.**
|
||||
> [ADR 0040](0040-module-stylesheets-and-artifact-isolation.md) supplies the three missing pieces and
|
||||
> reverses this one decision: a module may import plain `.css` from a browser-artifact source root,
|
||||
> the canonical builder compiles it **into** the declared artifact as text (so no sidecar is ever
|
||||
> emitted, which is what this section was protecting), and the host installs it into a Shadow DOM
|
||||
> root it creates for every artifact. No manifest field was needed after all. The rest of this ADR —
|
||||
> esbuild, bundled React, fixed source paths, no author bundler configuration, unminified output — is
|
||||
> unchanged.
|
||||
|
||||
### Browser TypeScript stays scoped to UI source
|
||||
|
||||
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 0038: Email verification and password recovery, with a trusted-bootstrap exception
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Extends: [0010](0010-core-identity-and-better-auth-adoption.md),
|
||||
[0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||
[0034](0034-host-owned-browser-authentication.md),
|
||||
[0035](0035-initial-platform-owner-bootstrap.md),
|
||||
[0036](0036-managed-device-identity-and-pairing.md),
|
||||
[0037](0037-host-provided-outbound-email.md)
|
||||
|
||||
## Context
|
||||
|
||||
Ekklesia can authenticate a person, hold a session, pair a device, and — since
|
||||
[ADR 0037](0037-host-provided-outbound-email.md) — send a transactional email. What it cannot do is
|
||||
either half of the ordinary human identity lifecycle: it has no way to establish that an address
|
||||
belongs to the person who claimed it, and no way for someone who has forgotten their password to
|
||||
get back in. The Better Auth hooks for both have existed and been deliberately unimplemented since
|
||||
[ADR 0011](0011-identity-http-transport-and-public-enrollment.md); Pass 1 built the transport they
|
||||
were waiting for.
|
||||
|
||||
Adding them is not simply "turn the feature on", because two things about this platform are unusual.
|
||||
|
||||
The **initial platform owner** is created by an operator-run command
|
||||
([ADR 0035](0035-initial-platform-owner-bootstrap.md)) on a freshly provisioned server, at a moment
|
||||
when SMTP may not be configured, may not be reachable, and may not yet have a domain to send from.
|
||||
A verification requirement applied naively would produce a correctly installed platform whose only
|
||||
administrator can never sign in to configure the very mail service the requirement depends on.
|
||||
|
||||
And **password recovery is an anonymous, unauthenticated mutation** that sends mail to an address
|
||||
the caller chose. That is the classic account-enumeration surface, and it is also the classic way to
|
||||
turn a platform into someone else's mail cannon.
|
||||
|
||||
## Decision
|
||||
|
||||
### Better Auth owns the mechanics; Ekklesia owns the experience
|
||||
|
||||
```text
|
||||
host-owned browser screens ← Ekklesia
|
||||
↓
|
||||
Ekklesia auth boundary ← Ekklesia
|
||||
↓
|
||||
Better Auth ← tokens, hashing, expiry, consumption, session policy
|
||||
↓ "deliver this"
|
||||
identity notification adapter ← Ekklesia: recipient, subject, bodies
|
||||
↓
|
||||
MailProvider seam → SMTP ← ADR 0037
|
||||
```
|
||||
|
||||
Ekklesia implements **no** token table, token hashing, token expiry, or token consumption. Those
|
||||
exist and are correct in the pinned release, and a second implementation would be a second thing to
|
||||
get wrong. A structural test asserts that no Ekklesia schema gains a reset or verification token
|
||||
column and that identity code never hashes or compares one.
|
||||
|
||||
### A verified address is a precondition for holding a session
|
||||
|
||||
`emailAndPassword.requireEmailVerification` is enabled. In the pinned release this is enforced
|
||||
*after* the password is validated and *before* any session is created: an unverified sign-in fails
|
||||
with `403 EMAIL_NOT_VERIFIED` and **no cookie is set**.
|
||||
|
||||
That single fact is what keeps the principal model unchanged. There is no "unverified principal",
|
||||
because there is no session to attach one to — a correct password on an unverified address produces
|
||||
exactly the same authority as no password at all. `/api/v1/principal` continues to describe only
|
||||
`anonymous`, an authenticated human, and a managed device, and the Dashboard shell continues to
|
||||
render authenticated content only after the principal endpoint says so. Nothing was added to the
|
||||
public principal union, and nothing needed to be.
|
||||
|
||||
### The initial platform owner is created already verified
|
||||
|
||||
`createInitialPlatformOwner` marks the address verified — writing Better Auth's own `emailVerified`
|
||||
column, so the resulting row is indistinguishable from one that verified normally — before it grants
|
||||
ownership. If that write fails, the grant and the one-time sentinel roll back together and the
|
||||
command remains available.
|
||||
|
||||
The exception's authority is the same authority that already makes the command safe: **possession of
|
||||
the database, not possession of the mailbox.** It is not a weaker policy, it is a different and
|
||||
stronger proof, available exactly once per installation. `pnpm identity:create-initial-owner` gains
|
||||
no mail prerequisite of any kind.
|
||||
|
||||
An adopted identity is marked verified too. The alternative — bootstrap succeeding and producing an
|
||||
owner who cannot sign in — is the failure this whole exception exists to prevent.
|
||||
|
||||
Migration `0007_verified_bootstrap_owners` marks currently-granted platform owners of *existing*
|
||||
installations verified, so enabling this policy cannot lock an operator out of a platform they
|
||||
already run. It is deliberately narrow: it touches only identities the authorization tables already
|
||||
record as owners, and no other account.
|
||||
|
||||
### Links are built from configuration, never from a request
|
||||
|
||||
Better Auth hands its callbacks a prebuilt URL embedding whatever `callbackURL` or `redirectTo` the
|
||||
requesting client supplied, and validates those against its trusted origins. That validation is
|
||||
sound. It is nonetheless not what Ekklesia relies on: the notification adapter takes **only the
|
||||
token** and rebuilds the link from validated configuration.
|
||||
|
||||
The result is that no request parameter can influence where a recipient lands, independently of
|
||||
whether any origin check behaves as expected. An emailed link is the one artifact a person clicks
|
||||
without looking.
|
||||
|
||||
The destination is a new validated `IdentityConfig.dashboardOrigin` — the **Dashboard** browser
|
||||
origin, which defaults to the identity base URL (the ordinary same-origin shape) and may be
|
||||
configured separately (`EKKLESIA_DASHBOARD_ORIGIN`) for a deployment that splits its API from its
|
||||
Dashboard. It is validated to be a canonical origin, https and non-loopback in production, and a
|
||||
Better Auth trusted origin — so a misconfiguration fails at startup instead of producing a link
|
||||
every recipient is refused at. It is explicitly **not** the public church website origin: this is
|
||||
the authenticated Dashboard host and nothing else.
|
||||
|
||||
### Following a link never mints a session
|
||||
|
||||
`emailVerification.autoSignInAfterVerification` is `false`. Verification links travel through mail
|
||||
infrastructure and are routinely dereferenced by things that are not the recipient — link scanners,
|
||||
prefetchers, corporate mail gateways. Turning that GET into a logged-in session would hand a session
|
||||
to whatever fetched the URL. Verification flips the flag and the person signs in afterwards.
|
||||
|
||||
This also makes replay harmless, which matters: the verification token is a signed JWT rather than a
|
||||
stored row, so it is **not single-use** — it is valid until it expires (one hour). Replaying it on an
|
||||
already-verified address is a no-op that mints nothing. The reset token *is* a stored row and *is*
|
||||
consumed, so a captured reset link cannot be replayed.
|
||||
|
||||
### Password reset revokes that human's sessions
|
||||
|
||||
`emailAndPassword.revokeSessionsOnPasswordReset` is enabled. Recovery is what someone uses when they
|
||||
believe their credential is compromised; leaving the attacker's session alive would make it
|
||||
cosmetic. This is Better Auth's own supported option, not a Core reach into the session table.
|
||||
|
||||
It deletes *all* of that person's sessions, including the browser performing the reset — so the
|
||||
completion screen returns to sign-in rather than pretending to be authenticated. **Managed device
|
||||
sessions are unaffected**: a device is a separate credential digest keyed by device
|
||||
([ADR 0036](0036-managed-device-identity-and-pairing.md)), holds no email and no password, and has
|
||||
no row in the human session table.
|
||||
|
||||
### Recovery is enumeration-safe, including when mail is broken
|
||||
|
||||
The user-facing answer is identical for an address with an account, an address without one, an
|
||||
already-verified address, and an address whose message could not be delivered.
|
||||
|
||||
Making that true required one thing beyond the library's own behaviour. Better Auth's
|
||||
`/send-verification-email` rethrows whatever the delivery callback throws, which would make "this
|
||||
address has an unverified account" observable the moment SMTP broke. **The notification adapter
|
||||
therefore never throws.** A failure is reported out of band with a sanitized code and no address,
|
||||
token, or link, where no HTTP response shape depends on it.
|
||||
|
||||
### Sending is still direct, non-retrying, and rate-limited per process
|
||||
|
||||
[ADR 0037](0037-host-provided-outbound-email.md)'s discipline is unchanged: one bounded attempt, no
|
||||
application-level retry. A timeout after SMTP DATA is genuinely ambiguous, and retrying it sends the
|
||||
same reset link twice.
|
||||
|
||||
Better Auth's own limiter applies **3 requests per 60 seconds per IP** to password-reset requests and
|
||||
verification resends, and 3 per 10 seconds to sign-in. That limiter is in-memory and per process.
|
||||
It is a real abuse control for a single deployment and is deliberately **not** claimed to be a
|
||||
distributed one; a multi-process deployment gets that budget per process. Resending is also reachable
|
||||
by simply attempting to sign in (`emailVerification.sendOnSignIn`), which costs nothing to secure
|
||||
because it requires the correct password.
|
||||
|
||||
### Mail-less installations still run
|
||||
|
||||
An installation with no SMTP configured starts, serves, and signs its bootstrap owner in. It
|
||||
configures the identical verification policy and simply cannot deliver; every attempt reports
|
||||
`MAIL_NOT_CONFIGURED` through the reporter. Absent mail does **not** relax
|
||||
`requireEmailVerification` — which is precisely why the bootstrap exception exists.
|
||||
|
||||
The operator requirement is therefore: **normal production use of verification and recovery requires
|
||||
configured outbound email; initial platform-owner bootstrap does not.**
|
||||
|
||||
### Public signup remains disabled
|
||||
|
||||
Email verification is not a licence to self-register. `POST /api/auth/sign-up/email` stays refused,
|
||||
production still cannot enable it at all, and the browser shell offers no registration control. A
|
||||
regression test asserts each. Future human accounts will arrive through deliberate administration or
|
||||
invitation, which this decision does not design.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A human account's lifecycle is complete: it exists, verifies, signs in, recovers, and signs out.
|
||||
- The principal union, the device model, and the module-facing surface are all unchanged.
|
||||
- Ekklesia Lite is unaffected: it has no human authentication, so it acquires no SMTP requirement.
|
||||
- Verification and recovery are now the second and third things that will break if outbound email is
|
||||
misconfigured. `pnpm doctor`'s `outbound-email.configuration` check is the place an operator finds
|
||||
that out.
|
||||
- Deferred deliberately: invitations, user administration, email-change verification, multiple
|
||||
addresses, MFA, OAuth/SSO, a durable mail outbox, and distributed rate limiting.
|
||||
@@ -0,0 +1,156 @@
|
||||
# 0039: Shared design tokens and foundational elements
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||
[ADR 0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0021 made a module an independently versioned product that owns its own UI, and ADR 0022 chose
|
||||
React as the way to author it. Since then the platform has been able to *mount* a module's screen but
|
||||
has said nothing at all about what that screen should look like. The consequences are now concrete:
|
||||
|
||||
- Every screen in this repository — the Dashboard shell, the display host, the module template —
|
||||
hardcodes its own hex colours, font stack, spacing, and radii. They already disagree with each
|
||||
other.
|
||||
- A module author has no vocabulary for "the ordinary panel background" or "the danger text colour",
|
||||
so the only available answer is to invent one. Two modules will invent two.
|
||||
- Deferred repeatedly and named in ADR 0022, ADR 0023, and ADR 0033's deferral lists: "a design
|
||||
system, component library, or theme/design-token contract."
|
||||
- A theme editor is a real product requirement for a church platform, and it is only implementable
|
||||
if the values it would edit are enumerable with human-readable names — which is a property of the
|
||||
*contract*, not something that can be retrofitted onto a pile of literals.
|
||||
|
||||
The constraint that shapes everything below is ADR 0024's browser boundary. Whatever the shared
|
||||
design layer is, module UI imports it, so it must not be able to pull a backend type graph — and with
|
||||
it Node's global declarations — into a browser program.
|
||||
|
||||
## Decision
|
||||
|
||||
Add two shared browser-safe packages and open exactly two boundary roles to module UI.
|
||||
|
||||
### `@ekklesia/design-tokens` — the semantic token registry
|
||||
|
||||
A dependency-free package (like `@ekklesia/module-ui-sdk`, and for the same reason) holding one
|
||||
validated registry of **semantic** tokens: `background.page`, `text.danger`, `state.hover-background`,
|
||||
`typography.size-heading-1`, and so on. Nothing is named for the colour it currently happens to be,
|
||||
because the point is that a value can change without a consumer's meaning changing with it.
|
||||
|
||||
Each token declares a name, a human-readable label, a description, and a default value. Its
|
||||
**category and CSS custom-property name are derived** from the name rather than declared, so a token
|
||||
can never claim one category while being named for another, and there is exactly one spelling of
|
||||
`--ekklesia-<name>`. That is what makes the registry enumerable: a future theme editor reads
|
||||
`list()`, groups by `category`, labels each field with `label`, explains it with `description`, and
|
||||
needs no second source of truth.
|
||||
|
||||
Construction validates every rule rather than leaving it to review: an invalid or single-segment
|
||||
name, an unknown category, a malformed derived identifier, an empty or overlong label or description,
|
||||
a duplicate name, and a value that is not a **bounded literal** each throw. A value may not contain a
|
||||
declaration terminator, a rule block, a comment, an at-rule, `url(`, `!important`, a backslash
|
||||
escape, a control character, or markup, and is length-bounded. A registry that constructs is
|
||||
therefore a registry that can be emitted as CSS without escaping, because nothing needing escaping
|
||||
can be in it.
|
||||
|
||||
The registry emits its defaults as one CSS rule, scoped to `:root` or `:host` — a closed set, never a
|
||||
caller-supplied selector, since an arbitrary selector string is exactly the unbounded value the
|
||||
package refuses everywhere else.
|
||||
|
||||
### `@ekklesia/ui` — the foundational elements
|
||||
|
||||
A small React package whose every colour, type, spacing, radius, shadow, and interaction state is a
|
||||
`var(--ekklesia-…)` reference. It contains the set a real module dashboard cannot be built without:
|
||||
button and icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and
|
||||
list primitives, badge, toast presentation, and empty/error/loading states, with `:focus-visible`
|
||||
focus treatment on every interactive element.
|
||||
|
||||
Each element is a thin, accessible shell over the real HTML element rather than a re-implementation
|
||||
of it, so native attributes, form association, and assistive-technology semantics pass straight
|
||||
through. **This is deliberately not a large catalogue**: an element earns its place when two
|
||||
independent consumers need the same one.
|
||||
|
||||
**Status is never signalled by colour alone.** Every status family carries a symbol token beside its
|
||||
colours, and the elements render it. A status that is only red is invisible to a person who cannot
|
||||
distinguish it, and unreadable on a projector or a washed-out lobby screen (WCAG 2.2 §1.4.1).
|
||||
|
||||
### The stylesheet is a string, not a `.css` file
|
||||
|
||||
These elements are mounted in two very different places: a document (the Dashboard shell, a
|
||||
standalone host page) and an isolated root (a module's own artifact). A `.css` file would need a
|
||||
bundler on both sides and a served asset on one of them. A string is installed into whichever root
|
||||
the caller actually has, with identical bytes — and it is what lets `:focus-visible`, `:hover`, and
|
||||
`:disabled` be real CSS rather than JavaScript approximations of them, which is the difference
|
||||
between a focus treatment that works for a keyboard user and one that only looks like it does.
|
||||
|
||||
`<EkklesiaUiStyles />` renders that string into whichever root its React tree lives in. There is
|
||||
deliberately no `document.head` hoisting and no `precedence` attribute: hoisting an isolated
|
||||
artifact's styles into the host document is precisely the leak the platform must not have.
|
||||
|
||||
### Modules consume tokens; they never invent global ones
|
||||
|
||||
A module may reference any registered token and may derive **private, differently-prefixed aliases**
|
||||
from them inside its own artifact. A module may not define an `--ekklesia-*` custom property: that
|
||||
would add a global theme variable outside the registry, which a theme editor could never enumerate
|
||||
and another module could unknowingly depend on. The typed `designToken(name)` reference makes an
|
||||
unregistered name a compile error and a runtime failure.
|
||||
|
||||
### The boundary opens by name, not by category
|
||||
|
||||
`pnpm boundaries` adds `design-tokens` and `ui` to the shared roles a module may depend on. It is a
|
||||
named allowlist, not "browser-safe shared packages are fine": `api-client`, `auth-client`,
|
||||
`surface-sdk`, and `module-ui-host` all stay rejected for modules, and `contracts`/`module-sdk`
|
||||
remain excluded from surfaces for the reason ADR 0024 measured.
|
||||
|
||||
The two new packages are pinned at the bottom of the presentation stack by their own rule:
|
||||
`design-tokens` may depend on nothing but dev-time tooling, and `ui` may depend on `design-tokens`
|
||||
and dev-time tooling. A design package that could reach an API client would put that client inside
|
||||
every module artifact that renders a button.
|
||||
|
||||
### The host installs the theme once, at its root
|
||||
|
||||
`surfaces/shell` installs the default token values at the document for both the Dashboard and the
|
||||
display application. CSS custom properties inherit through a shadow boundary, so every module
|
||||
artifact — including one mounted in an isolated root — reads `var(--ekklesia-…)` without the host
|
||||
reaching into the artifact or the artifact importing the host.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Two packages exist where none did; both are browser-safe by construction and proven so by a real
|
||||
compile of module-shaped source under the exact project a module uses for UI, where `process.env`
|
||||
must fail to typecheck.
|
||||
- The Dashboard shell and display host no longer hardcode a colour, and a module can match them.
|
||||
- A theme editor becomes implementable without a contract change: the values it would edit are
|
||||
already enumerable, labelled, described, categorised, and validated.
|
||||
- A module now has more it *may* import. The allowlist is exactly two roles and is enforced, with
|
||||
fixtures proving both the acceptance and the continued rejection of everything else.
|
||||
- Every module artifact bundles its own copy of the elements, exactly as it already bundles React
|
||||
(ADR 0023). That cost is accepted for now and is optimisable later without a contract change,
|
||||
because the contract is still a mount function.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Ship a full design system.** Rejected: a large catalogue is a large maintenance and review
|
||||
surface, and most of it would be speculative. The set here is what a real module dashboard needs.
|
||||
- **Put the tokens in `@ekklesia/module-ui-sdk`.** Rejected: that package is the *host contract*, and
|
||||
a host that renders nothing still has to satisfy it. Design values are not something a host
|
||||
supplies, and merging them would make the mount ABI carry a theme.
|
||||
- **Expose tokens through `ModuleUiHostContext`.** Rejected: a host handing a module a token map
|
||||
makes every module re-implement styling from data, and makes the tokens a runtime negotiation
|
||||
rather than inherited CSS. Custom properties already inherit into artifacts for free.
|
||||
- **Ship a `.css` file.** Rejected for this pass: it forces a bundler and a served asset on every
|
||||
consumer, including the isolated artifacts that most need the styles. A *module's own* stylesheets
|
||||
are a separate question — the module's build already exists to compile them — and are decided by
|
||||
their own ADR.
|
||||
- **A theme editor now.** Deferred deliberately. The registry is the prerequisite; the editor needs
|
||||
persistence, an authorization story, per-installation overrides, and a preview surface, none of
|
||||
which exist.
|
||||
|
||||
## Deferred
|
||||
|
||||
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
|
||||
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
|
||||
documentation site, and a focus-trap utility.
|
||||
@@ -0,0 +1,166 @@
|
||||
# 0040: Module stylesheets, and every browser artifact gets an isolated root
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0039](0039-shared-design-tokens-and-elements.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0023 rejected stylesheet imports in module UI, and gave an honest reason: esbuild would emit a
|
||||
`.css` sidecar beside the artifact, nothing would declare it, no host would fetch it, and it would
|
||||
*work in a local test and silently do nothing in a real host*. Inline style objects were the whole
|
||||
supported story, and a stylesheet contract was deferred to "a manifest field, a host loading
|
||||
mechanism, and a style-isolation decision, none of which exist yet."
|
||||
|
||||
Two of those three now exist or are unnecessary, and the remaining gap is blocking real work:
|
||||
|
||||
- Inline style objects cannot express `:hover`, `:focus-visible`, `:disabled`, `@media`, or
|
||||
`@keyframes`. A production management screen needs all of them, and an accessible focus treatment
|
||||
needs `:focus-visible` specifically. This is not a preference; it is a capability inline styles do
|
||||
not have.
|
||||
- ADR 0039's shared elements are built from real CSS for exactly that reason, so a module using them
|
||||
already needs somewhere for a stylesheet to live.
|
||||
- A contributed renderer (a later ADR) makes the isolation question urgent rather than theoretical:
|
||||
one module's renderer mounted inside another module's screen is precisely where a leaked selector
|
||||
does damage nobody can trace.
|
||||
|
||||
The manifest field turns out not to be needed, and the loading mechanism and the isolation decision
|
||||
turn out to be the same decision.
|
||||
|
||||
## Decision
|
||||
|
||||
### A stylesheet is compiled **into** the declared artifact, as text
|
||||
|
||||
A module imports its local `.css` files. `@ekklesia/module-ui-build` compiles each one — resolving
|
||||
local `@import`s through a nested bundle — into a JavaScript module whose default export is that
|
||||
stylesheet's text. The author passes the text to `defineModuleUi({ styles })`.
|
||||
|
||||
That is why no manifest field is required. There is still exactly **one** declared file per browser
|
||||
role, containing everything: nothing new to publish, nothing for a host to fetch, no second URL, and
|
||||
no way for a stylesheet to become an undeclared sidecar — the failure ADR 0023 named. The builder
|
||||
additionally fails if any CSS output file is ever produced beside an artifact, so a future option or
|
||||
loader change cannot quietly reintroduce one.
|
||||
|
||||
Two rules are enforced on the compiled text, each with its own sanitized error code:
|
||||
|
||||
- **A module stylesheet may reference Ekklesia's global theme variables and must never declare one.**
|
||||
Declaring `--ekklesia-*` would invent a global outside the registry a future theme editor
|
||||
enumerates (ADR 0039). A private alias under the module's own prefix is the supported way to name
|
||||
a local value, and is explicitly allowed.
|
||||
- **An `@import` that survives compilation is rejected.** A local import is inlined and leaves
|
||||
nothing behind, so anything remaining is a remote or unresolvable reference, and an artifact that
|
||||
fetches a stylesheet at run time is not self-contained.
|
||||
|
||||
Plain `.css` only. `.scss`, `.less`, `.styl`, and friends stay rejected: adding a preprocessor is a
|
||||
language and toolchain decision needing its own ADR, not a detail of a build pass.
|
||||
|
||||
`pnpm boundaries` allows `.css` inside a module **only** under the browser-artifact source roots the
|
||||
canonical builder actually compiles (`src/ui/`, `src/display/`, and the contribution sources a later
|
||||
ADR adds). Anywhere else in a module a stylesheet is a file no build reads and no host loads — the
|
||||
undeclared sidecar again, just relocated. Every other stylesheet dialect stays rejected everywhere.
|
||||
|
||||
### Every browser artifact mounts inside an isolated root
|
||||
|
||||
The generic loader creates a Shadow DOM root inside the host's container, installs the artifact's
|
||||
stylesheet there, and hands the artifact an element inside that root:
|
||||
|
||||
```text
|
||||
host container ← the host's element; the loader never mutates it beyond this one child
|
||||
└─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
|
||||
#shadow-root (open)
|
||||
├─ <style data-ekklesia-module-ui-styles> base rules, then the artifact's declared CSS
|
||||
└─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
|
||||
```
|
||||
|
||||
Shadow DOM is chosen because it is the one mechanism in a browser that **enforces** the rule rather
|
||||
than describing it: rules inside a shadow root do not apply outside it, and document rules do not
|
||||
apply inside it. A class-name convention, a build-time prefix, or a "please scope your selectors"
|
||||
instruction are conventions that one mistake defeats, and the failure mode is a module silently
|
||||
restyling the Dashboard or another module's renderer.
|
||||
|
||||
This applies to **every** role — primary UI, display UI, and contributed renderers — unconditionally,
|
||||
including artifacts that declare no stylesheet. One mounting path, not a styled one and an unstyled
|
||||
one, because a conditional isolation boundary is one a module can accidentally fall out of.
|
||||
|
||||
Both wrapper elements are `display: contents` through a base stylesheet the loader installs *before*
|
||||
the artifact's own, so the loader's two elements contribute no boxes and an artifact lays out exactly
|
||||
as it did before isolation existed — and an artifact that genuinely needs a box (a full-viewport
|
||||
display) overrides `:host` by ordinary cascade order rather than by anything the host special-cases.
|
||||
|
||||
`mode: "open"` is deliberate. **This is not a security boundary, and must not be described as one.**
|
||||
An installed module is trusted first-party code running in the host's own realm (ADR 0022, ADR 0024);
|
||||
it could reach `document` directly if it wanted to. `closed` would buy no protection while making the
|
||||
artifact's DOM unreachable to the host's own tests and diagnostics. This is a correctness boundary
|
||||
for *styles*.
|
||||
|
||||
Design tokens still reach the artifact: CSS custom properties inherit through a shadow boundary, so a
|
||||
host that installed the theme at its root (ADR 0039) has already supplied every artifact on the page.
|
||||
|
||||
### Mount and unmount are deterministic, in both directions
|
||||
|
||||
Each mount builds a fresh root with exactly one `<style>`. Unmount runs the module's cleanup and then
|
||||
removes the whole root — content and styles together — in a `finally`, so a module whose cleanup
|
||||
throws still has its stylesheet taken out of the page. Remounting cannot accumulate a second copy
|
||||
because there is nothing left to accumulate onto. A mount that throws disposes the root it built, so
|
||||
a failed artifact leaves neither partial DOM nor installed styles behind.
|
||||
|
||||
### The mount ABI is versioned, and the cutover is hard
|
||||
|
||||
An artifact's DOM is no longer a direct child of the host's element, and the definition gained a
|
||||
field. That is observable, so `MODULE_UI_ABI_VERSION` is now `2` and `defineModuleUi` stamps it —
|
||||
the author never writes it, so it can never drift from the SDK the artifact actually compiled
|
||||
against.
|
||||
|
||||
There is no dual acceptance, exactly as there is none for a manifest version (ADR 0006, ADR 0014,
|
||||
ADR 0021, ADR 0033). A version-1 artifact fails with `MODULE_UI_ABI_UNSUPPORTED` — checked first and
|
||||
on its own, so the message is "rebuild your module" rather than a developer hunting for a typo.
|
||||
Migration is one `pnpm build`: the canonical builder owns every option, and a module that declares no
|
||||
stylesheet needs no source change at all.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Module UI can express hover, focus-visible, disabled, media queries, and keyframes, so ADR 0039's
|
||||
accessible focus treatment actually works inside a module artifact.
|
||||
- One module's stylesheet provably cannot reach the Dashboard, another module, or another renderer,
|
||||
and the Dashboard's cannot reach in. Two artifacts of the *same* module are isolated from each
|
||||
other too: they share styles only if they share a source file.
|
||||
- A host's own tests reach an artifact's DOM through its open root rather than through
|
||||
`container.querySelector`. Every mounting test in this repository was updated accordingly; that
|
||||
churn is the visible price of the boundary being real.
|
||||
- Every module must be rebuilt once. Nothing else changes for an author who uses no stylesheet.
|
||||
- The artifact is larger by the size of its CSS, exactly as it is larger by the size of React
|
||||
(ADR 0023). It remains one file.
|
||||
- A module still cannot reference a bundled image or font from CSS: no asset loader is configured, so
|
||||
such a reference fails the build. Data URLs work; object storage remains the answer for real media.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Keep inline styles only.** Rejected: it cannot express the pseudo-classes an accessible control
|
||||
needs, and the workaround — JavaScript hover and focus tracking — is worse in every way.
|
||||
- **Publish a `.css` sidecar and have the host `<link>` it.** Rejected: it needs a manifest field, a
|
||||
second publication route, a second cache policy, and a second failure mode, and it reintroduces the
|
||||
exact "a file nothing declares" hazard ADR 0023 refused. Compiling into the one declared artifact
|
||||
costs none of that.
|
||||
- **Scope styles by build-time class prefixing instead of Shadow DOM.** Rejected: a convention a
|
||||
single unprefixed selector defeats, with a silent failure mode and no way for the host to prove
|
||||
containment. It also cannot stop the *host's* rules from reaching into an artifact.
|
||||
- **Isolate only artifacts that declare a stylesheet.** Rejected: an artifact would fall in and out
|
||||
of isolation as a source file changed, and a module rendering the shared elements' `<style>` from
|
||||
inside React would leak into the host document without ever importing a `.css` file.
|
||||
- **`mode: "closed"`.** Rejected as security theatre: the artifact is same-realm trusted code, and
|
||||
closing the root only blinds the host's own diagnostics.
|
||||
- **Accept both ABI versions.** Rejected for consistency with every manifest cutover this repository
|
||||
has made: dual acceptance means one artifact can be read two ways, and the migration here is a
|
||||
rebuild rather than an edit.
|
||||
|
||||
## Deferred
|
||||
|
||||
CSS preprocessors, CSS Modules, bundled image and font assets, `adoptedStyleSheets` for shared
|
||||
constructable stylesheets across artifacts, a host-supplied reset inside artifacts beyond the two
|
||||
layout rules, and any claim that Shadow DOM confines module *code*.
|
||||
@@ -0,0 +1,193 @@
|
||||
# 0041: One module may contribute a browser renderer to another module's capability
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md)
|
||||
|
||||
## Context
|
||||
|
||||
The platform already has a complete story for one module using another's *backend*: capabilities,
|
||||
versioned dependency ranges, and provider registration (ADR 0006). It has no story at all for one
|
||||
module using another's *browser* code, and the first real product needs one.
|
||||
|
||||
The concrete shape, stated so the mechanism can be judged against it rather than against an
|
||||
abstraction:
|
||||
|
||||
```text
|
||||
Signage provides a backend capability and owns a screen made of timeline slots
|
||||
Announcements requires that capability and registers a backend provider for it
|
||||
Announcements also ships a React renderer that knows how an announcement should look
|
||||
Signage mounts that renderer for one slot, drives it, and takes it down
|
||||
```
|
||||
|
||||
Every alternative available today is wrong in a way that matters. Signage cannot import
|
||||
Announcements: modules are independently installed, independently versioned peers, and
|
||||
`pnpm boundaries` rejects it outright. Signage cannot re-implement the renderer: it does not own the
|
||||
domain and the two would drift on the first change. Announcements cannot declare a second primary UI:
|
||||
`entrypoints.ui` and `entrypoints.display` are artifacts a *host* mounts in surfaces the host owns,
|
||||
and nothing about "a slot inside another module's timeline" is a host concern. And the platform must
|
||||
not learn what a slide is — the moment Core has a `slide` type, every future consumer inherits
|
||||
Signage's vocabulary.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a generic **browser contribution**: a compiled renderer one module declares for a capability
|
||||
another module provides, mounted by that other module inside a container it owns.
|
||||
|
||||
### The manifest declaration is inert and small
|
||||
|
||||
`manifestVersion: 7` adds a top-level `contributions` array. Each entry declares:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `id` | `<module-id>.<local-name>` — namespaced, so uniqueness needs no registry |
|
||||
| `version` | The contribution's own version, independent of its module's |
|
||||
| `capability` | The capability it plugs into, owned by whichever module provides it |
|
||||
| `capabilityRange` | The provider versions it is compatible with |
|
||||
| `artifact` | One compiled browser artifact, package-relative |
|
||||
|
||||
That is the complete list, and the omissions are the decision. There is no payload schema, no slot,
|
||||
no ordering, no rendering hint, and no domain vocabulary of any kind. **Core cannot learn what a
|
||||
slide is from this declaration, because the declaration cannot express it.**
|
||||
|
||||
Validation is static and fails closed: the id must be namespaced to the declaring module and have
|
||||
exactly one local segment; ids and artifact paths must be unique within a module; the artifact must
|
||||
not collide with any entrypoint; and a contribution may not target a capability its own module
|
||||
provides — a module contributing to itself needs no platform mechanism and would make the
|
||||
compatibility check a comparison of a module with itself.
|
||||
|
||||
Version 7 is a hard cutover, exactly as every prior manifest version has been (ADR 0006, 0014, 0021,
|
||||
0033). Upgrading a module that contributes nothing is a one-character edit.
|
||||
|
||||
### Publication is the existing mechanism, extended
|
||||
|
||||
Discovery resolves each contribution artifact through **the identical loop** every other compiled
|
||||
entrypoint uses, so a contribution inherits the path grammar, containment, symlink-resolution,
|
||||
extension, and file-kind checks rather than receiving a second, weaker implementation of them. Two
|
||||
contributions that resolve through a link to one file are rejected.
|
||||
|
||||
The publication registry adds contributions on three conditions, all of which must hold: the
|
||||
contributing module is in the eligible set, *some other* eligible module provides the target
|
||||
capability, and that provider's declared capability version satisfies the declared range. A
|
||||
contribution that fails any of them is **absent** — never published-but-broken, and never mounted
|
||||
with a shrug.
|
||||
|
||||
`apps/api` serves it at `GET /api/v1/module-ui/<module-id>/<version>/contributions/<contribution-id>`
|
||||
— a sibling of the artifact and display routes, under the same prefix, with the same policy: three
|
||||
lookup keys, one exact registry match, one already-validated canonical path, no request value ever
|
||||
composed into a filesystem path, and one indistinguishable `404` for unknown, undeclared, stale, and
|
||||
incompatible alike. Source maps, sibling chunks, and package-root files stay unreachable by
|
||||
construction.
|
||||
|
||||
### A consumer discovers through the host, never through a URL
|
||||
|
||||
The consuming module's descriptor gains a `contributions` array: identity, version, capability, and
|
||||
the **server-produced** URL. A module UI sees only the first three; the URL lives in the host, which
|
||||
resolves it exactly as it resolves `api.request("records")`. A module that built
|
||||
`/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would be welded to Ekklesia's private
|
||||
deployment detail and could never run under a standalone host.
|
||||
|
||||
`ModuleUiHostContext` gains a sixth member, `contributions`, with `list(capabilityId)` and
|
||||
`mount({ contributionId, container, input })`. A consumer can only ever see contributions aimed at a
|
||||
capability **it** provides, because that is the only thing the server put in its descriptor — and it
|
||||
cannot name its way to anything else, because a `mount` for an id the server withheld fails without
|
||||
fetching anything.
|
||||
|
||||
### The consumer owns the domain contract, entirely
|
||||
|
||||
`input` is opaque JSON. The platform validates that it *is* genuinely JSON-serializable — structurally,
|
||||
so a `Date`, a function, `undefined`, a non-finite number, or a cycle fails at the boundary rather
|
||||
than vanishing somewhere far from the mistake — and validates nothing about its shape or meaning. A
|
||||
consumer publishes its own input contract alongside its capability and versions it through its own
|
||||
capability version. Neither the manifest, the registry, the route, nor the SDK has a place to put a
|
||||
schema, which is what stops one from appearing.
|
||||
|
||||
### The contribution ABI is separate and versioned
|
||||
|
||||
`MODULE_CONTRIBUTION_ABI_VERSION` is stamped by `defineModuleContribution`, never written by an
|
||||
author. It is deliberately **not** the same number as `MODULE_UI_ABI_VERSION`: the two contracts have
|
||||
different lifecycles — one receives a host context, the other receives a consumer's input and can be
|
||||
updated in place — and versioning them together would force every module to rebuild whenever either
|
||||
moved. A mismatched artifact fails with its own code before mounting.
|
||||
|
||||
The ABI supports exactly what a long-running screen needs:
|
||||
|
||||
- **asynchronous readiness** — `mount` may return a promise, so a consumer can sequence a transition
|
||||
instead of guessing when a renderer has decoded its media;
|
||||
- **update without remounting** — remounting per change would destroy DOM, restart animations, and
|
||||
reload media on every tick. A renderer that genuinely cannot update in place omits `update`, and
|
||||
the consumer is told to remount rather than left rendering stale content behind a silently ignored
|
||||
call;
|
||||
- **deterministic cleanup** — the loader runs the renderer's cleanup and removes its isolated root in
|
||||
a `finally`, so a renderer that fails to clean up still has its styles taken out of the page;
|
||||
- **sanitized failure** — every code is fixed, and no message, stack, or `cause` ever carries the
|
||||
consumer's domain data.
|
||||
|
||||
Each contribution mounts inside its own Shadow DOM root (ADR 0040) with its own compiled stylesheet,
|
||||
so a contributed renderer cannot restyle the consuming module's screen around it, and that module's
|
||||
rules cannot reach in.
|
||||
|
||||
### What a contribution is not given
|
||||
|
||||
No database, no object storage, no credential, no session, no API client, and no reach into any
|
||||
module — including the one that contributed it. Its context is two members: its own id and the
|
||||
consumer's input.
|
||||
|
||||
**Loading a contribution grants its module nothing.** Server-side authorization is unchanged and
|
||||
remains the only authority. Everything a contributed renderer needs arrives through the consumer's
|
||||
input, because the consumer is the party that was authorized to obtain it.
|
||||
|
||||
A contribution artifact is still installed, trusted, first-party code in the host's realm (ADR 0022,
|
||||
ADR 0024). Its isolated root is a correctness boundary for styles and must never be described as a
|
||||
sandbox.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One module can extend another's screen without either importing the other, and without the
|
||||
platform learning either one's vocabulary.
|
||||
- The Dashboard bundle now contains two dynamic imports rather than one, both the generic loader's:
|
||||
a module UI artifact and a contributed renderer.
|
||||
- Manifest version 7 requires every module to update one field. Nothing else changes for a module
|
||||
that contributes nothing.
|
||||
- `ModuleUiHostContext` grew from five members to six. It is present on every context and empty for
|
||||
the overwhelming majority of modules, so a module writes the same code whether or not anything was
|
||||
contributed — and a standalone host running one module supplies an empty implementation that is
|
||||
the *correct* one rather than a stub.
|
||||
- A consuming module must handle an empty list: a contribution can be absent because nobody
|
||||
contributed, because the contributor is disabled, or because it is outside the compatible range.
|
||||
That is the same discipline an optional capability dependency already requires.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Let the consumer import the contributor.** Rejected: it is the layer violation the boundary
|
||||
checker exists to prevent, and it would make two independently installable modules one unit.
|
||||
- **Put the renderer in the consumer.** Rejected: the consumer does not own the domain, so the
|
||||
renderer and the data it draws would drift on the first change to either.
|
||||
- **A second `entrypoints` role.** Rejected: the two existing roles are artifacts a *host* mounts in
|
||||
surfaces the host owns. A contribution is mounted by a module, at a moment and in a place only that
|
||||
module understands; calling it an entrypoint would put the platform in charge of deciding when a
|
||||
slot should be filled.
|
||||
- **A platform-defined payload schema.** Rejected, emphatically. It would make Core the owner of a
|
||||
vocabulary it cannot possibly own, force every future consumer to inherit the first one's shape,
|
||||
and require a platform release to change a domain contract between two modules.
|
||||
- **Give the contribution its own API client.** Rejected: a renderer with its own authority is a
|
||||
privilege the consumer never granted, and a second authorization path to reason about. The consumer
|
||||
was authorized to fetch the data; it passes what it fetched.
|
||||
- **Let a consumer construct the artifact URL.** Rejected for the same reason a module may not build
|
||||
its own API prefix (ADR 0022): it welds the module to Ekklesia's deployment detail.
|
||||
- **One shared ABI version for UI artifacts and contributions.** Rejected: it couples two contracts
|
||||
with different shapes and lifecycles, and would make every module rebuild whenever either moved.
|
||||
|
||||
## Deferred
|
||||
|
||||
Contribution ordering and priority, a consumer-declared "extension point" manifest declaration,
|
||||
per-contribution permissions, contributions to *backend* extension points, host-mediated
|
||||
communication back from a contribution to its own module, contribution-level configuration, hot
|
||||
reload, and a module store or installer that would let contributions arrive without a restart.
|
||||
@@ -0,0 +1,160 @@
|
||||
# 0042: Explicit, bounded streaming request bodies
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||
[ADR 0017](0017-public-request-perimeter-hardening.md),
|
||||
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||
[ADR 0031](0031-direct-object-delivery.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0017 gave every public route an enforced request-body ceiling and capped a module route at
|
||||
1 MiB, with an explicit note that "large media transfer belongs to a future dedicated storage
|
||||
design, never to widening this ceiling."
|
||||
|
||||
The storage design arrived — ADR 0030 gave modules a scoped object store, ADR 0031 took Ekklesia out
|
||||
of the *read* path with signed delivery URLs — and it did not solve the *write* path. ADR 0031
|
||||
deliberately declined to offer signed write URLs, so the only way bytes reach a module's bucket is
|
||||
through that module's own HTTP route. That route is capped at 1 MiB, and an ordinary photograph is
|
||||
not 1 MiB. A church uploading a slide for a lobby screen cannot, today, get the file in.
|
||||
|
||||
Two properties of the existing implementation also need naming, because they are what the change has
|
||||
to preserve and what it has to fix:
|
||||
|
||||
- The pinned body-limit middleware **fully buffers** any body with no declared `Content-Length`
|
||||
before deciding. At 1 MiB that is a bounded, acceptable cost. At image sizes it would mean the
|
||||
platform holding every concurrent upload in memory.
|
||||
- With a declared `Content-Length` within the limit it *counts nothing at all*, trusting the header.
|
||||
Node's HTTP framing makes that safe today, but it is trust the platform does not need to extend.
|
||||
|
||||
## Decision
|
||||
|
||||
Extend the existing route contract. There is no second HTTP system, no upload endpoint, no signed
|
||||
write URL, and no new middleware stack — one route declaration, one limiter, one `413`.
|
||||
|
||||
### A route declares how its body reaches it
|
||||
|
||||
`ModuleRouteDefinitionInput` gains one optional field:
|
||||
|
||||
```ts
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/uploads",
|
||||
permission: "signage.slides.write",
|
||||
maxBodyBytes: 33_554_432,
|
||||
bodyMode: "streamed",
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
- **`buffered`** (the default, and what every route written before this meant) — the platform reads
|
||||
and counts the whole body **before** the handler runs, so the handler is promised a fully
|
||||
available, bounded body and an oversized request is rejected before any authorization or handler
|
||||
work happens. Ceiling: 1 MiB, unchanged.
|
||||
- **`streamed`** — the platform counts bytes **as the handler consumes them** and never materializes
|
||||
the body. Ceiling: 32 MiB.
|
||||
|
||||
The mode is **explicit rather than inferred from the size**. Inferring it — "over 1 MiB means
|
||||
streamed" — would mean an author who raised a limit silently changed how their own handler receives
|
||||
its body, and when a `413` can arrive relative to their own code. That is exactly the class of
|
||||
invisible change this repository refuses.
|
||||
|
||||
Omitting the field means `buffered`, so no existing route changed behaviour, and the canonical parsed
|
||||
form always carries a mode so nothing downstream re-applies the default or disagrees about it.
|
||||
|
||||
### The ceilings, and why 32 MiB
|
||||
|
||||
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` stays at 1 MiB for buffered bodies: a body the platform
|
||||
materializes is a body the platform is holding, and 1 MiB is the most a JSON request has any business
|
||||
being.
|
||||
|
||||
`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` is 32 MiB, sized to hold one 25 MiB image plus bounded
|
||||
overhead — a multipart envelope, its boundaries and part headers, and room for a client that pads. It
|
||||
is also `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.** A route
|
||||
declaring more than its mode's ceiling fails registration, exactly as an over-ceiling value always
|
||||
has.
|
||||
|
||||
The unit is deliberately **one file per request**. A UI offering twenty images issues twenty
|
||||
individually bounded requests, each of which the platform can reject cheaply and each of which fails
|
||||
alone; a 500 MiB aggregate request would make one bad byte waste everything already sent, and nothing
|
||||
in this contract can express one.
|
||||
|
||||
### Enforcement, in one limiter
|
||||
|
||||
The platform's own limiter replaces the buffering dependency. It is the same code for both modes and
|
||||
differs only in *when* it counts:
|
||||
|
||||
1. A declared `Content-Length` above the limit is refused synchronously — before authorization,
|
||||
before the handler, before a single body byte is read. That is the cheap rejection an ordinary
|
||||
too-large upload takes. A malformed or contradictory header is treated as *absent* rather than as
|
||||
a rejection, because the byte counting bounds the request either way and refusing could only turn
|
||||
a legitimate request away.
|
||||
2. `buffered`: 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, which the previous implementation did not do. At most
|
||||
the route's own declared allowance is ever held.
|
||||
3. `streamed`: the body is wrapped in a counting transform and handed to the handler. The moment the
|
||||
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 answered anyway**. A
|
||||
limit a handler can swallow is not a limit.
|
||||
|
||||
A body no handler read is cancelled after the request, so an abandoned upload releases its socket
|
||||
rather than draining. An aborted upload surfaces as an ordinary failed request, never a `413`:
|
||||
nothing exceeded a limit.
|
||||
|
||||
Ordering is unchanged and remains deliberate: trusted-origin check, then the body contract, then
|
||||
permission evaluation, then the handler. 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 the platform at all — strictly better than the buffered path.
|
||||
|
||||
Timeouts stay where they were: the server's own `headersTimeout`/`requestTimeout`. A slow upload is a
|
||||
slow *request*, and the platform already has one answer for that rather than a second per-route one.
|
||||
|
||||
### What a module author must know
|
||||
|
||||
In `streamed` mode a `413` can arrive **after** the handler has started. A handler that persists as
|
||||
it reads must be prepared for a partially consumed upload — write to a temporary key and commit only
|
||||
after the read completes. The platform cannot do that for the module, because only the module knows
|
||||
what a partial write means to its own data.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An ordinary image upload works, through the route contract a module already had, with the platform
|
||||
holding none of it.
|
||||
- The buffered path now counts bytes it previously trusted a header for, closing a gap rather than
|
||||
opening one.
|
||||
- Every existing route and every existing test is unchanged: the default is the old behaviour, and
|
||||
the `413` contract, ordering, and sanitization are identical.
|
||||
- `apps/api` owns ~60 lines of stream plumbing it previously delegated. That is the price of a
|
||||
limiter that neither buffers a large body nor can be swallowed by a handler, and it is enforced by
|
||||
tests rather than by reading the dependency's source.
|
||||
- Media larger than 32 MiB — a video — still has no path in. That is deliberate and named below.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Raise the single ceiling to 32 MiB.** Rejected: every existing route would silently gain the
|
||||
ability to have 32 MiB buffered in memory on its behalf, and `await request.json()` in a module
|
||||
handler would stop being a bounded operation.
|
||||
- **Infer streaming from `maxBodyBytes`.** Rejected: it makes a size change silently change delivery
|
||||
semantics and error timing for the author's own code.
|
||||
- **A separate upload endpoint or second HTTP system.** Rejected: a second route table, a second
|
||||
authorization path, a second CORS and origin policy, and a second failure vocabulary, to solve
|
||||
something the existing route contract can express with one field.
|
||||
- **Signed write URLs to object storage.** Deliberately still deferred (ADR 0031). A signed write URL
|
||||
is a bearer capability to *create* an object, which needs quota, content-type, and lifecycle
|
||||
answers this pass does not have — and a module that wants to validate, transform, or authorize per
|
||||
byte cannot use one anyway.
|
||||
- **Let the handler enforce its own limit.** Rejected: a limit enforced by the party being protected
|
||||
against is not a limit, and every module would reimplement it slightly differently.
|
||||
- **Trust `Content-Length` in streamed mode.** Rejected: the running byte count costs nothing and
|
||||
removes the need to reason about framing at all.
|
||||
|
||||
## Deferred
|
||||
|
||||
Signed write URLs, resumable or chunked multi-request uploads, per-module or per-installation upload
|
||||
quotas, content-type or magic-byte validation, virus scanning, image transformation, progress
|
||||
reporting, and any body larger than the absolute ceiling — a video pipeline is its own design, not a
|
||||
larger number here.
|
||||
@@ -0,0 +1,173 @@
|
||||
# 0043: The display caches its shell and declared code, and nothing else
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[ADR 0031](0031-direct-object-delivery.md),
|
||||
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||
[ADR 0041](0041-module-browser-contributions.md)
|
||||
|
||||
## Context
|
||||
|
||||
A display is the one surface in this platform that is expected to outlive its network. It is a
|
||||
television on a wall in a building whose uplink is a consumer router, nobody is standing in front of
|
||||
it, and the person who could press a button is not in the building on a Tuesday.
|
||||
|
||||
Today, when that network drops and the screen reboots — a power blip, a scheduled restart, a
|
||||
janitor's foot — the browser shows **its own error page**. Not the display shell, not an Ekklesia
|
||||
message, not a retry: a network failure with no Ekklesia in it at all, until someone notices and
|
||||
does something about it. Nothing about that page recovers on its own, because there is no Ekklesia
|
||||
code running on it to recover.
|
||||
|
||||
The temptation is to solve this by caching more: the descriptor, the principal, the module's data,
|
||||
so a screen can keep showing content through an outage. That is exactly the wrong instinct. A cached
|
||||
descriptor is a cached permission decision, and a screen replaying one is a revoked device still
|
||||
showing content it is no longer entitled to.
|
||||
|
||||
## Decision
|
||||
|
||||
Cache **the shell and the declared code**, and nothing else, in a service worker scoped to the
|
||||
display.
|
||||
|
||||
### What is cached
|
||||
|
||||
- The display page (`/display/<module-id>`).
|
||||
- The fixed display host script.
|
||||
- The versioned browser artifacts the display actually loaded: its module's display artifact, and
|
||||
every contributed renderer it mounted (ADR 0041).
|
||||
|
||||
All three are code. All three are already public bytes that confer no authority (ADR 0025, ADR 0041).
|
||||
All three are addressed by a URL that carries an exact version, so a cache entry is unambiguous about
|
||||
what it is.
|
||||
|
||||
### What is never cached, under any condition
|
||||
|
||||
The display descriptor, `/api/v1/principal`, every other platform and module API response, the
|
||||
realtime stream, pairing requests and responses, and **every cross-origin request** — which is what a
|
||||
signed object-delivery URL is (ADR 0031), a bearer capability with an expiry that a cache would
|
||||
outlive.
|
||||
|
||||
This is an **allowlist**, not a deny list. A deny list has to be updated every time the platform gains
|
||||
an endpoint, and the cost of forgetting is a stored permission decision. Here the cost of forgetting
|
||||
is that a new kind of code is simply not cached, which is a bug rather than a vulnerability.
|
||||
|
||||
### The consequence, stated rather than papered over
|
||||
|
||||
**A display cannot render module content while its network is down.** Mounting requires a descriptor;
|
||||
a descriptor is authorization-bearing; it is never served from a cache. A revoked device therefore
|
||||
stays revoked, and cached code can never manufacture current authorization — the two properties this
|
||||
pass must not trade away.
|
||||
|
||||
What the cache buys is real and worth having on its own:
|
||||
|
||||
- A kiosk that reboots during an outage boots into **Ekklesia's** honest offline state instead of the
|
||||
browser's error page, and keeps retrying by itself.
|
||||
- The moment connectivity returns it resolves and mounts with **nothing left to download** — the
|
||||
600 KB artifact is already there.
|
||||
- A momentary failure fetching an artifact, after a descriptor already resolved, falls back to the
|
||||
identical bytes instead of failing the whole display. The descriptor is tiny and the artifact is
|
||||
not, so this is the failure that actually happens.
|
||||
|
||||
Module-owned offline content — a module persisting its own state and media so a screen can keep
|
||||
showing yesterday's slides — remains the module's responsibility. This is the code foundation it
|
||||
would build on.
|
||||
|
||||
### Network first, always
|
||||
|
||||
Every cached kind is fetched from the network first and only falls back when that fetch fails. A
|
||||
kiosk therefore picks up new code the moment the server returns and can never be trapped on an
|
||||
obsolete build — which is precisely the failure mode a cache-first worker produces on an unattended
|
||||
screen, and the one that requires physically visiting it to fix.
|
||||
|
||||
Only a complete, successful, same-origin `200` is ever stored. A `304`, a `404`, a redirect, an
|
||||
opaque response, and a `503` from an API mid-restart are all things a screen must never later replay
|
||||
as though they were the artifact.
|
||||
|
||||
### Versioned keys and pruning
|
||||
|
||||
Cache names carry a policy version; superseded generations are deleted on activation, so a policy
|
||||
change cannot leave a screen serving entries from rules that no longer apply.
|
||||
|
||||
Artifact URLs already carry an exact module version, so a new build lands *beside* the old one. After
|
||||
storing an artifact the worker removes every entry for the same module at a different version — and
|
||||
deliberately keeps every entry at the current version, because a module's display artifact and each
|
||||
contributed renderer are separate URLs under one version and dropping any of them would half-cache a
|
||||
working display. Another module's artifacts are never touched.
|
||||
|
||||
The worker never caches **itself**, which is the other way a screen gets stuck: it is served
|
||||
`no-cache` with a strong validator, exactly like the host script.
|
||||
|
||||
### Scope is the directory
|
||||
|
||||
The worker is served at `/display/sw.js`, chosen for its *directory*: a service worker's default
|
||||
scope is where it was served from, so this one controls display pages and nothing else. The Dashboard
|
||||
is never controlled by it and no Dashboard request is ever intercepted. Serving it beside the host
|
||||
script under `/assets/` would scope it to a directory where no page lives; widening it with
|
||||
`Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||
|
||||
A controlled page's requests are intercepted wherever they point, which is how an artifact under
|
||||
`/api/v1/module-ui/` is cached without widening the scope to the whole origin.
|
||||
|
||||
### Reporting, not deciding
|
||||
|
||||
The page gets a small browser-safe signal built from two independent sources, because either alone
|
||||
would lie: `navigator.onLine` (cheap, immediate, famously optimistic — a captive portal reports
|
||||
`true`) and the worker's own report that it actually served from cache (ground truth about this
|
||||
application). A display renders a quiet corner indicator, never a full-page failure: a screen whose
|
||||
switch rebooted must not blank a lobby wall.
|
||||
|
||||
When the browser reports the network is back, the display resolves its descriptor again on its own.
|
||||
Nobody presses Retry on a lobby television, and that fresh descriptor is also what stops a screen
|
||||
sitting on cached code after a deployment — it names the current version, and the network-first worker
|
||||
fetches it.
|
||||
|
||||
Registration is an **enhancement and never a prerequisite**: a browser without service workers, a page
|
||||
served over plain HTTP in development, and a policy that refuses all leave a display that works
|
||||
exactly as it did before, online only.
|
||||
|
||||
### Testable by construction
|
||||
|
||||
Every caching decision lives in a module that touches no global: the cache storage, `fetch`, and the
|
||||
way a status reaches the page all arrive as an environment. The worker file is thin wiring that
|
||||
supplies the real ones. That is what lets the behaviour be exercised directly — first load, offline
|
||||
reload, a missing uncached artifact, contribution fallback, version upgrade and pruning, reconnect,
|
||||
and the proof that nothing authorization-bearing is ever stored — rather than through a service
|
||||
worker a test runner cannot host.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unattended screen degrades to an Ekklesia message and recovers by itself, instead of degrading
|
||||
to a browser error page and staying there.
|
||||
- The platform serves a third fixed display asset, read once before the listener binds like the other
|
||||
two.
|
||||
- A display page is now controlled by a service worker, which is a real operational surface: an
|
||||
operator debugging a screen must know a worker is in front of it. It is `no-cache`, claims clients
|
||||
immediately, and caches only code, so the ways it can go wrong are bounded.
|
||||
- Offline *content* is still absent, and that is the honest state of this foundation rather than an
|
||||
oversight.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Cache the descriptor so a display can mount offline.** Rejected outright. It is a permission
|
||||
decision and a device identity; replaying one is a revoked screen still showing content.
|
||||
- **Cache-first for speed.** Rejected: it is how an unattended screen ends up serving a build from
|
||||
months ago with no way to notice, and fixing it means visiting the screen.
|
||||
- **Precache everything at install.** Rejected: the worker would have to know which module a screen
|
||||
runs and at which version — facts only the descriptor carries — and an install would fail whenever
|
||||
an asset a future version needs did not exist yet.
|
||||
- **Cache at the whole origin with `Service-Worker-Allowed`.** Rejected: the Dashboard gains a worker
|
||||
it has no use for, for no benefit.
|
||||
- **Use `navigator.onLine` alone.** Rejected: it reports `true` behind a captive portal and a dead
|
||||
uplink, so a screen would insist it was fine while showing nothing.
|
||||
- **Store artifacts in IndexedDB and import them from a blob URL.** Rejected: it costs a host its
|
||||
`script-src` CSP, exactly as ADR 0024 rejected for the loader, and reimplements what a cache
|
||||
already does correctly.
|
||||
|
||||
## Deferred
|
||||
|
||||
Module-owned offline content and media persistence, background sync, periodic background update,
|
||||
push, a Dashboard service worker, offline pairing, precaching a display's artifact before its first
|
||||
visit, and any cached response that carries identity or authorization.
|
||||
@@ -0,0 +1,178 @@
|
||||
# 0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources
|
||||
|
||||
- Status: Proposed
|
||||
- Date: 2026-10-01
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0003](0003-development-infrastructure.md),
|
||||
[ADR 0004](0004-replace-unmaintained-minio.md),
|
||||
[ADR 0017](0017-public-request-perimeter-hardening.md),
|
||||
[ADR 0018](0018-identity-session-resolution-and-database-transport.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||
[ADR 0031](0031-direct-object-delivery.md),
|
||||
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||
[ADR 0034](0034-host-owned-browser-authentication.md),
|
||||
[ADR 0035](0035-initial-platform-owner-bootstrap.md),
|
||||
[ADR 0043](0043-display-offline-code-fallback.md)
|
||||
|
||||
## Context
|
||||
|
||||
Phases 1–8 built and verified the platform contracts, but `README.md` and
|
||||
`docs/development/security.md` both record that Ekklesia is not deployed: there is no production
|
||||
instance, reverse proxy, public listener, Dashboard asset hosting, or deployment automation. The
|
||||
first real feature module, Signage, now exists in its own repository and needs a production host.
|
||||
|
||||
Several gaps only become concrete with a real deployment:
|
||||
|
||||
- Production requires certificate-verified PostgreSQL TLS (`verify-full` is the only production
|
||||
mode; ADR 0018), including for a database on the same host or Docker network.
|
||||
- Browsers require a secure context for the display service worker (ADR 0043), and production
|
||||
identity rejects `http` and loopback origins. A real, publicly trusted certificate is therefore
|
||||
required even on a private network.
|
||||
- Signage declares a storage bucket, so object storage is no longer optional for this deployment,
|
||||
and ADR 0004 left the production provider undecided.
|
||||
- A module repository is installed by cloning it into `modules/`. It depends on platform packages
|
||||
through `workspace:` ranges, so it can only be built inside an Ekklesia workspace, and the
|
||||
platform's committed `pnpm-lock.yaml` does not describe it.
|
||||
- There is no health endpoint, no backup procedure, and no upgrade procedure.
|
||||
|
||||
## Decision
|
||||
|
||||
Ekklesia provides **one reference self-hosted deployment**, kept in this repository under
|
||||
`deploy/`, that any organization can run on a single Linux host with Docker Compose. River of
|
||||
Glory Church is its first instance, but nothing church-specific enters this repository.
|
||||
|
||||
### Topology
|
||||
|
||||
One host, one Compose project, four long-running services on a private network:
|
||||
|
||||
| Service | Role |
|
||||
| --- | --- |
|
||||
| `proxy` | Caddy. Terminates TLS, serves the only published ports (443, 80 for redirect), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
|
||||
| `api` | The `apps/api` image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only. |
|
||||
| `postgres` | PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA. |
|
||||
| `object-storage` | SeaweedFS, the same image family as development, reachable by browsers only through the proxy. |
|
||||
|
||||
Plus one operator-run, non-restarting service, `migrate`, described below.
|
||||
|
||||
### The API serves the Dashboard
|
||||
|
||||
The built Dashboard (`surfaces/shell`, `index.html` and its bundle) is served by `apps/api` from
|
||||
the same origin as `/api`, exactly as the display host already is. This keeps the identity
|
||||
system's default same-origin shape (`EKKLESIA_DASHBOARD_ORIGIN` unset) and avoids a second static
|
||||
server. The proxy never serves files from disk.
|
||||
|
||||
### TLS everywhere, without weakening ADR 0018
|
||||
|
||||
- **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference
|
||||
configuration uses the HTTP-01 challenge; the DNS-01 challenge is documented for installations
|
||||
that keep Ekklesia off the public Internet. A LAN-only installation can still hold a real
|
||||
certificate for an internal hostname this way.
|
||||
- **Tunnel ingress (optional):** an installation may publish the proxy through an outbound tunnel
|
||||
(the reference documents `cloudflared`) instead of opening inbound ports. The tunnel connector
|
||||
is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's
|
||||
certificate. The proxy accepts a tunnel-supplied client-address header **only** from the
|
||||
connector's network identity. It overwrites the single forwarding header it sends to `api`, and
|
||||
`api` trusts only the proxy. An installation that serves local displays should give the same
|
||||
hostname a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
|
||||
Internet link.
|
||||
- **Database:** the deployment's setup script creates an installation-private CA once, issues a
|
||||
server certificate for the `postgres` service name, and gives the API only the CA certificate
|
||||
(`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only
|
||||
exception is added to Core.
|
||||
|
||||
### Object storage
|
||||
|
||||
SeaweedFS is the reference provider for this deployment. Before the first production instance
|
||||
holds data, the existing storage integration suite (`core/tests/module-storage.integration.ts`,
|
||||
already run by `pnpm test:migrations:integration`) must pass against the exact SeaweedFS image the
|
||||
deployment pins. Browsers reach signed object URLs on a dedicated storage origin
|
||||
(`S3_DELIVERY_ENDPOINT`); the proxy forwards the original `Host` header unchanged so S3 signatures
|
||||
remain valid (ADR 0031). Any other S3-compatible provider remains a supported substitution.
|
||||
|
||||
### Composition from pinned sources
|
||||
|
||||
A running installation is defined by an **instance composition**: the platform version and the
|
||||
exact module sources it includes. The image build:
|
||||
|
||||
1. checks out the platform at a release tag;
|
||||
2. checks out each listed module at a pinned tag or commit SHA into `modules/<id>`;
|
||||
3. installs with `pnpm install --frozen-lockfile` against a lockfile **committed with the
|
||||
instance composition**, not the platform's own lockfile;
|
||||
4. runs `pnpm verify`-equivalent module and migration validation, then builds.
|
||||
|
||||
The instance composition (module list, pins, lockfile, non-secret configuration) lives in a
|
||||
separate repository owned by the installation. The reference deployment documents its shape and
|
||||
provides an empty example. Two builds from the same composition produce the same installed code.
|
||||
|
||||
This is a deployment mechanism, not a module distribution format. A packaged, installable module
|
||||
artifact (built outside an Ekklesia workspace, against published SDK versions) is a separate
|
||||
future decision.
|
||||
|
||||
### Migrations stay explicit
|
||||
|
||||
The `migrate` service runs `migrations:plan` then, only when an operator invokes it,
|
||||
`migrations:apply`, from the same image as `api`. The `api` service never applies migrations. The
|
||||
documented upgrade procedure is: build image → back up → run `migrate` → restart `api`.
|
||||
|
||||
### Health
|
||||
|
||||
`apps/api` gains two unauthenticated endpoints that reveal nothing about installed modules:
|
||||
|
||||
- `GET /api/v1/health/live` — the process is serving HTTP.
|
||||
- `GET /api/v1/health/ready` — the database is reachable and module startup completed.
|
||||
|
||||
Both return a fixed JSON body and `Cache-Control: no-store`. Compose health checks and external
|
||||
monitoring use them. `pnpm doctor` remains the detailed operator report.
|
||||
|
||||
### Backups
|
||||
|
||||
The reference deployment includes a scheduled backup job producing a `pg_dump` custom-format
|
||||
archive and a copy of every bucket under the installation's `S3_BUCKET_PREFIX`, written to a
|
||||
location outside the host. A restore procedure is documented and must be exercised once before
|
||||
an installation is treated as in production.
|
||||
|
||||
### Secrets
|
||||
|
||||
Secrets are supplied to Compose from a root-owned environment file on the host, never committed.
|
||||
This includes `EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
|
||||
`EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Ekklesia has a reproducible, documented way to run in production for the first time, and the
|
||||
ten-item launch checklist in `docs/development/security.md` becomes checkable against concrete
|
||||
configuration.
|
||||
- No Core guarantee is relaxed. The database TLS rule, explicit migrations, and the trusted-code
|
||||
module model are unchanged.
|
||||
- The platform and modules begin release tagging, because an instance composition can only pin
|
||||
what has a stable name.
|
||||
- An installation that wants Cloudflare Tunnel, a different proxy, or a managed database can
|
||||
substitute it, but the reference configuration is the one CI exercises.
|
||||
- Building modules inside a platform workspace remains a known limitation until a module
|
||||
packaging decision replaces the per-instance lockfile.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Bare-metal systemd services.** Rejected as the reference: harder to reproduce and pin. Remains
|
||||
possible for installations that prefer it.
|
||||
- **Caddy-internal CA for browsers.** Rejected: every display device and admin browser would need
|
||||
the CA installed, which is unrealistic for volunteer-run screens.
|
||||
- **Relaxing database TLS inside a private Docker network.** Rejected: ADR 0018 deliberately has no
|
||||
such exception.
|
||||
- **Applying migrations automatically at API startup.** Rejected: contradicts the explicit-operator
|
||||
rule.
|
||||
- **Using `pnpm install` without a frozen lockfile when adding modules.** Rejected: the installed
|
||||
dependency graph would differ between builds.
|
||||
- **Kubernetes.** Rejected for the reference: disproportionate for a single church server.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Module packaging and distribution (published SDK packages, signed module artifacts, an
|
||||
installer).
|
||||
- Multi-host, high availability, and managed-database guidance.
|
||||
- Container image publishing and automated deployment from CI.
|
||||
- Remote-access gates in front of the Dashboard (for example Cloudflare Access), documented as
|
||||
installation choices rather than platform requirements.
|
||||
- An authorized offline display descriptor or lease (tracked separately; required before relying
|
||||
on displays to cold-start without the server).
|
||||
@@ -46,3 +46,10 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
|
||||
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
||||
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
||||
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
||||
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Proposed
|
||||
|
||||
@@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
|
||||
independent.
|
||||
|
||||
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
|
||||
dependencies, consumed by module authors and hosts alike. Its host context is five members —
|
||||
dependencies, consumed by module authors and hosts alike. Its host context was five members at that
|
||||
checkpoint and is six today (ADR 0041) —
|
||||
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
|
||||
`api.request(path)` with a path relative to the module's own API root, never by constructing
|
||||
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
|
||||
@@ -244,7 +245,8 @@ it, and hosts perform their own dynamic import.
|
||||
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
|
||||
option in one `tooling` package so module authors write no build configuration. React is bundled
|
||||
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
|
||||
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
|
||||
styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040),
|
||||
and a stylesheet import then failed the build rather than emitting an
|
||||
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
|
||||
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
|
||||
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
|
||||
@@ -573,3 +575,80 @@ An authenticated result preserves the response, session, and mounted UI. Display
|
||||
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
|
||||
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
|
||||
and the missing production first-user creation path are also outside this pass.
|
||||
|
||||
**Phase 8 supplies the five platform prerequisites the first production feature module needs.** None
|
||||
of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by
|
||||
something other than a feature module.
|
||||
|
||||
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md) adds the design layer the platform
|
||||
never had. `@ekklesia/design-tokens` is one validated, enumerable registry of **semantic** tokens —
|
||||
`background.raised`, `text.danger`, `state.hover-background` — where a token's category and
|
||||
`--ekklesia-*` identifier are *derived* from its name and every value must be a bounded literal, so a
|
||||
registry that constructs is one that can be emitted as CSS without escaping. That validation is
|
||||
exactly what makes a future theme editor implementable rather than aspirational: the values it would
|
||||
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
|
||||
elements a real module dashboard cannot be built without, every value a token reference, with real
|
||||
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens
|
||||
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
|
||||
else. The Dashboard and display install the defaults at their document root; custom properties
|
||||
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
|
||||
run time and nothing for a host to forget to pass.
|
||||
|
||||
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) reverses ADR 0023's
|
||||
inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports
|
||||
plain `.css`; the canonical builder compiles it **into** the declared artifact as text, so there is
|
||||
still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting
|
||||
against. Two rules are enforced with their own codes: a module stylesheet may reference
|
||||
`--ekklesia-*` and never declare one, and every `@import` must resolve locally. The generic loader
|
||||
then mounts **every** browser artifact inside its own open Shadow DOM root with its own stylesheet
|
||||
installed, unconditionally, because a conditional isolation boundary is one a module can fall out of
|
||||
by editing a source file. That root is a correctness boundary for *styles* and is never described as
|
||||
a sandbox: it is `open` precisely because module code is trusted, same-realm code that could reach
|
||||
`document` anyway. The browser mount ABI is versioned (`MODULE_UI_ABI_VERSION`, stamped by
|
||||
`defineModuleUi`) with no dual acceptance; migration is one `pnpm build`.
|
||||
|
||||
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
|
||||
renderer to a capability another module provides — the browser half of the capability system Core has
|
||||
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target
|
||||
capability, a compatible range, and one artifact. That is the complete list: there is no payload
|
||||
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
|
||||
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
|
||||
identical loop every other compiled entrypoint uses; publication requires that *another* eligible
|
||||
module provides the capability within the declared range, and 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. The input is opaque JSON validated for serializability and nothing else, so the
|
||||
domain contract stays entirely between the two modules. A contributed renderer receives its own id
|
||||
and that input: no API client, realtime, principal, configuration, database, storage, or credential,
|
||||
and loading one grants its module nothing.
|
||||
|
||||
[ADR 0042](decisions/0042-bounded-streaming-request-bodies.md) closes the write path ADR 0031 left
|
||||
open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the *read* path, but declined
|
||||
signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at
|
||||
1 MiB. A route now declares `bodyMode`: `buffered` (the default, and what every existing route
|
||||
meant) keeps that 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. The mode is explicit rather
|
||||
than inferred from the size, because inferring it would silently change how an author's own handler
|
||||
receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one
|
||||
25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's
|
||||
own limiter replaces the buffering dependency: a declared oversize is refused before authorization,
|
||||
the buffered path now counts what actually 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 the error.
|
||||
|
||||
[ADR 0043](decisions/0043-display-offline-code-fallback.md) gives the display host-owned offline
|
||||
resilience, scoped precisely to **the shell and the declared code**. A service worker scoped to
|
||||
`/display/` caches the page, the host script, the module's display artifact, and every contributed
|
||||
renderer that display mounted — and never the descriptor, `/api/v1/principal`, any API response, the
|
||||
realtime stream, a pairing exchange, or any cross-origin request, which is what a signed
|
||||
object-delivery URL is. 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 current
|
||||
authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline
|
||||
state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is
|
||||
network-first everywhere, so a screen can never be trapped on obsolete code.
|
||||
|
||||
Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a
|
||||
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
|
||||
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
|
||||
included. Nothing in this pass claims any of them exists.
|
||||
|
||||
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
| --- | --- | --- |
|
||||
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
||||
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
||||
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
|
||||
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, the approved design packages, public contracts, React, and external backend libraries |
|
||||
| `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
|
||||
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
||||
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
||||
@@ -20,10 +20,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
|
||||
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
|
||||
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
|
||||
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
|
||||
`@ekklesia/module-ui-sdk`. Since
|
||||
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
|
||||
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
|
||||
(`src/ui/`, `src/display/`, `src/contributions/`); a stylesheet anywhere else in a module is a file
|
||||
no build reads and no host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
||||
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
|
||||
packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
|
||||
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||
a role modules could already depend on — owns the entire module UI build
|
||||
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
||||
real target.
|
||||
@@ -31,12 +35,47 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
import Core, an app, a surface, a builder, or another module. It also may not import
|
||||
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
|
||||
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
|
||||
- A module may consume exactly two shared **design** roles: `design-tokens` (`@ekklesia/design-tokens`)
|
||||
and `ui` (`@ekklesia/ui`), added by
|
||||
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md). This is a named allowlist, not a
|
||||
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
|
||||
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
|
||||
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
|
||||
it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
|
||||
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
|
||||
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
|
||||
layer, an API/auth client, or the backend contracts barrel, because a design package that could
|
||||
would put all of it inside every module artifact that renders a button.
|
||||
- A module **references** registered global semantic tokens and may derive private,
|
||||
differently-prefixed aliases from them inside its own artifact. It may never define an
|
||||
`--ekklesia-*` custom property: that would invent a global theme variable outside the registry,
|
||||
which a future theme editor could not enumerate and another module could unknowingly depend on.
|
||||
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
|
||||
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
|
||||
Node's global declarations — into browser code. Core still owns the containing
|
||||
application shell and all platform policy, and module UI reaches the host only through
|
||||
`@ekklesia/module-ui-sdk`.
|
||||
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||
- A module stylesheet is compiled **into** the one declared JavaScript artifact as text and installed
|
||||
by the host at mount; there is no sidecar file, no second publication route, and no manifest field
|
||||
for one. Every browser artifact is mounted inside its own Shadow DOM root the generic loader
|
||||
creates, so one module's styles cannot reach the Dashboard, another module, or another renderer,
|
||||
and the host's cannot reach in. That root is a correctness boundary for styles, never a sandbox:
|
||||
module code remains trusted, same-realm code. The browser mount ABI is versioned
|
||||
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
|
||||
semantics.
|
||||
- A module may declare **browser contributions** (`manifest.contributions`, v7): compiled renderers
|
||||
it offers to capabilities *other* modules provide
|
||||
([ADR 0041](decisions/0041-module-browser-contributions.md)). Core validates identity,
|
||||
compatibility, and the artifact path through the identical resolution every other compiled
|
||||
entrypoint uses, and knows nothing about what a contribution renders — there is no payload schema,
|
||||
slot, ordering, or rendering hint anywhere in the platform. A contribution is published only when
|
||||
another eligible module provides its target capability within the declared range; otherwise it is
|
||||
absent rather than broken. A consuming module discovers contributions through
|
||||
`ModuleUiHostContext.contributions` and mounts them by id; it never constructs a publication URL,
|
||||
and it can only ever see contributions aimed at a capability it itself provides. A contributed
|
||||
renderer receives its own id and the consumer's opaque JSON input, and no database, storage,
|
||||
credential, session, or API client of its own.
|
||||
- Manifest v7 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
||||
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
|
||||
@@ -80,7 +119,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
receiving only the host-neutral principal and permission hints already in the UI SDK.
|
||||
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
|
||||
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
|
||||
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
||||
API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the
|
||||
display shell and the declared browser artifacts it actually loaded
|
||||
([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor,
|
||||
`/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange,
|
||||
or any cross-origin request — so a revoked device stays revoked and cached code can never
|
||||
manufacture current authorization. Caching is network-first, so a screen cannot be trapped on
|
||||
obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
||||
module still owns that renderer.
|
||||
- Separate surfaces do not import each other's internals.
|
||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||
@@ -96,6 +141,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
|
||||
columns, relations, schema entrypoint, or migration internals.
|
||||
|
||||
The design packages are covered the same way: one fixture proves a module may depend on both, one
|
||||
proves every other shared role stays rejected, and one proves neither design package may depend
|
||||
upward.
|
||||
|
||||
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
||||
rejects every currently machine-checkable repository rule even before production packages fill
|
||||
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
||||
|
||||
+57
-12
@@ -153,8 +153,9 @@ status, `Set-Cookie`, and other headers but discards the native token/user respo
|
||||
The shell neither parses nor stores a token. It resolves `/api/v1/principal` again, and only an
|
||||
authenticated result permits the navigation request and module mount. Credential denial and
|
||||
service failure use distinct fixed messages, but no Better Auth response body, exception, email,
|
||||
password, URL, or stack reaches browser output or logging. The form deliberately offers no signup
|
||||
or password-reset link because neither is a supported production flow.
|
||||
password, URL, or stack reaches browser output or logging. The form offers **no signup** control,
|
||||
because public registration is not a supported production flow. It does offer **Forgot password?**,
|
||||
which is now a real backend flow (ADR 0038).
|
||||
|
||||
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
|
||||
resolves the principal again. The host does not equate hiding the shell with invalidating a session.
|
||||
@@ -182,6 +183,28 @@ redirect target and performs no post-login navigation, so external and protocol-
|
||||
have no redirect sink in this pass. See
|
||||
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
|
||||
|
||||
### The four anonymous screens
|
||||
|
||||
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
|
||||
forgot-password, verification (notice or result), and choose-a-new-password
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). None of
|
||||
them is authenticated, and none can become authenticated — `/api/v1/principal` still decides.
|
||||
|
||||
An emailed link returns to the Dashboard root carrying `?ekklesia_auth=verify-email` or
|
||||
`?ekklesia_auth=reset-password`. Query parameters rather than paths, deliberately: the shell is a
|
||||
static bundle, and a deep path would oblige every deployment's static host to rewrite unknown paths
|
||||
to `index.html` before an emailed link worked at all.
|
||||
|
||||
The entry point parses that query once, with a pure function that recognizes only those two values
|
||||
and **names no destination of any kind**, then removes it from the address bar with
|
||||
`history.replaceState` — so a reset token never enters browser history, a shareable URL, or a
|
||||
`Referer`. The token lives in React state for the life of one form and is never written to
|
||||
`localStorage`, `sessionStorage`, or a cookie.
|
||||
|
||||
A correct password on an unverified address is reported as its own state (`403 EMAIL_NOT_VERIFIED`,
|
||||
no cookie) so the shell can offer a resend rather than claim the password was wrong. It is never
|
||||
confused with an authenticated principal.
|
||||
|
||||
## Public registration policy
|
||||
|
||||
**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
|
||||
@@ -385,16 +408,28 @@ protection across restarts or replicas.
|
||||
|
||||
## Request body limits
|
||||
|
||||
Every public body-consuming route has an explicit, platform-enforced byte ceiling
|
||||
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified
|
||||
directly against its source to reject both an oversized declared `Content-Length` and an oversized
|
||||
declared-length-free streamed body without ever buffering the whole payload first):
|
||||
Every public body-consuming route has an explicit, platform-enforced byte ceiling, enforced by the
|
||||
platform's own limiter (`apps/api/src/body-limits.ts`):
|
||||
|
||||
| Surface | Limit |
|
||||
| --- | --- |
|
||||
| `/api/auth/*` (every Better Auth route) | 8 KiB |
|
||||
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
|
||||
| A module route (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||||
| A module route declaring `bodyMode: "buffered"` (the default) | Up to 1 MiB (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||||
| A module route declaring `bodyMode: "streamed"` | Up to 32 MiB (`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING`), which is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES` |
|
||||
|
||||
A declared `Content-Length` over the limit is refused synchronously, before authorization and before
|
||||
a single body byte is read. Beyond that the two module modes differ only in *when* the platform
|
||||
counts ([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||||
|
||||
- **`buffered`** materializes the body before the handler runs, counting as it arrives, so a lying or
|
||||
absent `Content-Length` is bounded by what actually arrived. The handler is promised a fully
|
||||
available body within its declared allowance, and the platform holds at most that allowance.
|
||||
- **`streamed`** never materializes the body: it is counted as the handler consumes it, and the
|
||||
moment the total passes the limit the stream errors, the source is cancelled, and the response
|
||||
becomes the same `413` — even if the handler caught the error and answered anyway. A `413` can
|
||||
therefore arrive *after* a streamed handler has started, which is the one consequence a module
|
||||
author must design for.
|
||||
|
||||
An oversized request always receives the identical stable, sanitized contract —
|
||||
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
|
||||
@@ -402,10 +437,20 @@ Auth's business logic, a Core mutation, permission evaluation (where avoidable),
|
||||
or module database behavior. For a state-changing route the body limit is enforced after
|
||||
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
|
||||
is never read for size at all, while an oversized same-origin request still fails before any
|
||||
authorization work runs. See [Module HTTP routes](#module-http-routes) for the module-route body
|
||||
contract itself, and [Module development](../module-development/README.md#http-routes) for the
|
||||
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module
|
||||
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
|
||||
authorization work runs. 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 the platform at all.
|
||||
|
||||
Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout` below, not by a
|
||||
second per-route timeout. A body no handler read is cancelled after the request, so an abandoned
|
||||
upload releases its socket rather than draining; an aborted upload surfaces as an ordinary failed
|
||||
request rather than a `413`, because nothing exceeded a limit.
|
||||
|
||||
See [Module HTTP routes](#module-http-routes) for the module-route body contract itself, and
|
||||
[Module development](../module-development/README.md#http-routes) for the module-authoring side of
|
||||
it. The absolute ceiling is one image-sized file per request: aggregate uploads and anything larger
|
||||
than 32 MiB remain out of scope, and signed write URLs are still deliberately not offered
|
||||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)).
|
||||
|
||||
## Node request and header timeouts
|
||||
|
||||
@@ -1187,7 +1232,7 @@ its role-specific URL only from the protected descriptor below.
|
||||
|
||||
## Module display host
|
||||
|
||||
Manifest version 6 adds one independently optional display declaration
|
||||
The manifest carries one independently optional display declaration
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
|
||||
|
||||
```json
|
||||
|
||||
@@ -137,7 +137,8 @@ enforces those permissions while the module is disabled, because it mounts no ro
|
||||
|
||||
## Module permission composition
|
||||
|
||||
A module manifest's `permissions` array (`manifestVersion: 4`,
|
||||
A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
|
||||
manifest is version 7 — see
|
||||
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||||
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||||
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||||
@@ -667,7 +668,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many
|
||||
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
||||
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
||||
rejection, malformed permission id rejection.
|
||||
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version
|
||||
- `packages/contracts/tests/module-manifest.test.ts` — exact-version acceptance and version
|
||||
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
||||
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
||||
path, and default-empty-list isolation across separate parses.
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
Ekklesia can send transactional email through a host-configured SMTP transport
|
||||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
|
||||
|
||||
**Nothing in the platform sends email yet.** This pass added the transport; email verification,
|
||||
password reset, and invitations are Phase 7C Pass 2. An installation with no SMTP configured is a
|
||||
complete, valid deployment — the platform starts and every existing feature works.
|
||||
Two things send email: **email verification** and **password recovery**
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Invitations
|
||||
do not exist.
|
||||
|
||||
An installation with no SMTP configured is still a complete, valid deployment — the platform starts,
|
||||
every existing feature works, and the initial platform owner can sign in. What it cannot do is verify
|
||||
a new account or recover a password, because both need a message to arrive. See
|
||||
[When no relay is configured](#when-no-relay-is-configured).
|
||||
|
||||
## Configuring a relay
|
||||
|
||||
@@ -17,6 +22,7 @@ complete, valid deployment — the platform starts and every existing feature wo
|
||||
| `EKKLESIA_SMTP_USERNAME` / `EKKLESIA_SMTP_PASSWORD` | Optional **as a pair**. Half a pair is refused. |
|
||||
| `EKKLESIA_SMTP_FROM_ADDRESS` | Required once a host is set. The only address this platform can send as. |
|
||||
| `EKKLESIA_SMTP_FROM_NAME` | Optional display name. |
|
||||
| `EKKLESIA_SMTP_HELO_NAME` | Optional. The name this client announces in `EHLO`. See [When a relay refuses the connection](#when-a-relay-refuses-the-connection). |
|
||||
|
||||
### Security modes are explicit
|
||||
|
||||
@@ -31,6 +37,51 @@ plaintext session waiting to happen. Naming the mode removes the ambiguity:
|
||||
|
||||
Certificate verification is always on and there is no setting that turns it off.
|
||||
|
||||
### When a relay refuses the connection
|
||||
|
||||
If a relay closes the connection before accepting anything — a `421`, or an
|
||||
`MAIL_PROVIDER_UNAVAILABLE` from a relay you can reach perfectly well with another client — suspect
|
||||
the **greeting** before suspecting the network.
|
||||
|
||||
An SMTP client has to identify itself in the `EHLO` command, and Ekklesia does not invent that name.
|
||||
With nothing configured, Nodemailer derives it from the machine's own hostname, and substitutes the
|
||||
address literal `[127.0.0.1]` whenever that hostname is not fully qualified — which is the normal
|
||||
state of a container, or of a VM nobody has named. A relay that requires a resolvable client identity
|
||||
rejects that greeting outright, and because the rejection lands before any message is offered it is
|
||||
indistinguishable from the relay being down.
|
||||
|
||||
This was a real deployment defect, and a generic one: the relay worked, `swaks` sent from the same
|
||||
machine, and only the greeting differed.
|
||||
|
||||
```text
|
||||
C: EHLO [127.0.0.1]
|
||||
S: 421-4.7.0 Try again later, closing connection. (EHLO)
|
||||
```
|
||||
|
||||
Set the name explicitly:
|
||||
|
||||
```bash
|
||||
EKKLESIA_SMTP_HELO_NAME=example.org
|
||||
```
|
||||
|
||||
```text
|
||||
C: EHLO example.org
|
||||
S: 250 ...
|
||||
```
|
||||
|
||||
Use a fully qualified domain name the deployment owns, typically its sending domain. The value is
|
||||
validated at startup: no scheme, no port, no brackets, no whitespace, no trailing dot, and at least
|
||||
one label plus a domain — a single unqualified name such as `localhost` is refused, because that is
|
||||
exactly the input Nodemailer already discards.
|
||||
|
||||
It is **not** a credential. It travels in the clear on every connection, so it appears in
|
||||
`pnpm doctor` output where an operator can check it against what the relay expects.
|
||||
|
||||
Leaving it unset preserves Nodemailer's own behaviour exactly. It is deliberately not defaulted to
|
||||
the sender's domain: a machine whose hostname *is* a valid FQDN may already be greeting correctly —
|
||||
some relays check the greeting against the connecting address's reverse DNS — and silently replacing
|
||||
a working identity would trade one deployment's bug for another's.
|
||||
|
||||
### Google Workspace SMTP Relay
|
||||
|
||||
The expected first production deployment, and a compatibility target rather than a dependency — no
|
||||
@@ -53,6 +104,11 @@ controls which envelope senders are accepted.
|
||||
deployment relies on IP authorization, the server's egress address has to be registered, and a
|
||||
change of hosting provider or NAT address will break sending until it is updated.
|
||||
|
||||
Such a relay is also likely to require a fully qualified `EHLO` name — this is the deployment that
|
||||
exposed the generic issue described in
|
||||
[When a relay refuses the connection](#when-a-relay-refuses-the-connection) — so
|
||||
`EKKLESIA_SMTP_HELO_NAME` is usually needed alongside the variables above.
|
||||
|
||||
The same variables address Microsoft 365, a transactional provider's SMTP endpoint, a local Postfix,
|
||||
or anything else that speaks the protocol. Switching between them is configuration, not code.
|
||||
|
||||
@@ -121,8 +177,36 @@ transport refusing to continue when the upgrade is not offered.
|
||||
|
||||
**No test sends real email and no live credential exists in this repository or in CI.**
|
||||
|
||||
## Next
|
||||
## What the platform actually sends
|
||||
|
||||
Phase 7C Pass 2 wires Better Auth's `sendResetPassword` and `emailVerification.sendVerificationEmail`
|
||||
hooks — both currently unimplemented — to this transport. See
|
||||
[Identity](identity.md#outbound-email-and-the-verification-seam).
|
||||
| message | when | contents |
|
||||
| --- | --- | --- |
|
||||
| Verify your Ekklesia email address | a new account signs in unverified, or asks for a resend | one link, an expiry sentence, and ignore-if-unexpected wording |
|
||||
| Reset your Ekklesia password | someone asks to recover an account | one link, an expiry sentence, and a note that the current password still works |
|
||||
|
||||
Both are plain text and HTML, both are generic Ekklesia templates with no branding and no marketing
|
||||
content, and both are built in `core/src/identity/notifications.ts` — which knows a recipient, a
|
||||
subject, and two bodies, and nothing about SMTP.
|
||||
|
||||
Links are rebuilt from validated configuration rather than taken from the request that triggered
|
||||
them, so no caller can steer where a recipient lands. They point at
|
||||
`EKKLESIA_DASHBOARD_ORIGIN` (defaulting to the identity base URL). See
|
||||
[Identity](identity.md#email-verification-and-password-recovery).
|
||||
|
||||
## When no relay is configured
|
||||
|
||||
Nothing crashes and nothing changes shape. A verification or recovery request answers exactly as it
|
||||
would with a working relay — the response must not reveal whether an account exists, and it must not
|
||||
start revealing it the moment SMTP breaks — and the operator is told out of band:
|
||||
|
||||
```text
|
||||
[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED
|
||||
```
|
||||
|
||||
That line carries a flow and a sanitized code. It never carries the address, the token, or the link.
|
||||
`pnpm doctor` reports the same absence under `outbound-email.configuration`.
|
||||
|
||||
The operator requirement is therefore:
|
||||
|
||||
- **normal production use of verification and recovery** → requires configured outbound email
|
||||
- **initial platform-owner bootstrap** (`pnpm identity:create-initial-owner`) → does not
|
||||
|
||||
+116
-24
@@ -253,12 +253,12 @@ logs, and error carry no relation name, column name, SQL, driver message, host,
|
||||
|
||||
## What is and is not operational
|
||||
|
||||
Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow
|
||||
are operational — see [API](api.md) and ADR 0034. Public registration remains a future product
|
||||
policy, not a production capability: it is disabled by default, may be explicitly enabled only for
|
||||
development/test, and production rejects it. No email verification, password recovery, invitation
|
||||
flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or
|
||||
roles/groups/teams/tenants model exists.
|
||||
Email/password sign-in, principal resolution, sign-out, the Dashboard's host-owned browser flow,
|
||||
email verification, and password recovery are operational — see [API](api.md), ADR 0034, and
|
||||
ADR 0038. Public registration remains a future product policy, not a production capability: it is
|
||||
disabled by default, may be explicitly enabled only for development/test, and production rejects it.
|
||||
No invitation flow, social or OAuth provider, passkey, magic link, two-factor authentication,
|
||||
email-change verification, or roles/groups/teams/tenants model exists.
|
||||
|
||||
Core's authorization foundation (platform owners, explicit grants, and an immutable permission
|
||||
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
|
||||
@@ -279,31 +279,93 @@ wins, so a browser holding both remains a person.
|
||||
`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses
|
||||
to decide whether a person is signed in, and a device is not one.
|
||||
|
||||
## Outbound email and the verification seam
|
||||
## Email verification and password recovery
|
||||
|
||||
Ekklesia has a host-provided SMTP transport
|
||||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md), see
|
||||
[Outbound email](email.md)) — but **no identity flow uses it yet.**
|
||||
A verified email address is a **precondition for holding a session**
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Better Auth
|
||||
enforces this after the password is checked and before any session exists, so an unverified sign-in
|
||||
returns `403 EMAIL_NOT_VERIFIED` and sets no cookie.
|
||||
|
||||
Better Auth exposes exactly two hooks for the flows that need mail, and this repository configures
|
||||
neither:
|
||||
That is why there is no "unverified principal": a correct password on an unverified address carries
|
||||
exactly the same authority as no password at all, and `/api/v1/principal` still reports only
|
||||
`anonymous`, an authenticated human, or a device.
|
||||
|
||||
| hook | flow |
|
||||
### Who owns what
|
||||
|
||||
| concern | owner |
|
||||
| --- | --- |
|
||||
| `emailAndPassword.sendResetPassword({ user, url, token })` | Password reset |
|
||||
| `emailVerification.sendVerificationEmail({ user, url, token })` | Email verification |
|
||||
| token minting, expiry, consumption, password hashing, session revocation | Better Auth |
|
||||
| recipient, subject, plaintext and HTML bodies, link construction | `core/src/identity/notifications.ts` |
|
||||
| SMTP host, port, TLS, credentials, sender identity | the mail provider ([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)) |
|
||||
| the browser screens a person actually sees | the shell (`surfaces/shell`) |
|
||||
|
||||
Phase 7C Pass 2 implements them over the mail transport. `startApi` already constructs the provider
|
||||
and closes it on every shutdown path, so the remaining work is to inject it into
|
||||
`createIdentityRuntime` and write the two message bodies.
|
||||
Ekklesia implements no token table, no token hashing, and no expiry clock. A structural test asserts
|
||||
that none appears.
|
||||
|
||||
**The `url` and `token` those hooks receive are credentials.** The entire security of both flows is
|
||||
that only the recipient sees them, so they must never be logged, never appear in an error, and never
|
||||
reach a diagnostic. The transport already logs nothing and refuses to echo a message body.
|
||||
### Token semantics, stated precisely
|
||||
|
||||
Until that pass lands: email verification, password recovery, and invitations do not exist, and
|
||||
`requireEmailVerification` stays off — turning it on without a working sender would lock every
|
||||
account out.
|
||||
| | verification | password reset |
|
||||
| --- | --- | --- |
|
||||
| representation | signed JWT (not stored) | random value stored in `ekklesia_core_verification` |
|
||||
| lifetime | 1 hour | 1 hour |
|
||||
| single-use | **no** — valid until it expires | **yes** — consumed on use |
|
||||
| replay | idempotent no-op, mints no session | refused with `INVALID_TOKEN` |
|
||||
|
||||
Verification is deliberately idempotent because mail scanners and prefetchers dereference links.
|
||||
Following one never creates a session (`autoSignInAfterVerification: false`), so a link fetched by
|
||||
something that is not the recipient hands out nothing.
|
||||
|
||||
### Links
|
||||
|
||||
Both links are rebuilt from validated configuration, taking **only the token** from Better Auth —
|
||||
never the `url` it prebuilds from a caller-supplied `callbackURL`/`redirectTo`. No request parameter
|
||||
can influence where a recipient lands.
|
||||
|
||||
They point at `IdentityConfig.dashboardOrigin` (`EKKLESIA_DASHBOARD_ORIGIN`), which defaults to the
|
||||
identity base URL, must be a canonical origin, must be https and non-loopback in production, and must
|
||||
be a trusted origin — checked at startup, so a misconfiguration fails once for the operator instead of
|
||||
for every recipient. It is the authenticated Dashboard host, not the public church website.
|
||||
|
||||
**The `url` and `token` are credentials.** Nothing logs them, nothing puts them in an error, nothing
|
||||
puts them in a diagnostic, and the browser strips the reset token from the address bar with
|
||||
`history.replaceState` before rendering the form.
|
||||
|
||||
### Recovery is enumeration-safe
|
||||
|
||||
Every user-facing answer is identical for an address with an account, an address without one, an
|
||||
already-verified address, and one whose message could not be delivered. The notification adapter
|
||||
therefore **never throws** — Better Auth's `/send-verification-email` rethrows what its callback
|
||||
throws, which would turn a broken relay into an account oracle. Failures are reported out of band:
|
||||
|
||||
```text
|
||||
[identity] email-verification email was not delivered: MAIL_NOT_CONFIGURED
|
||||
```
|
||||
|
||||
### Password reset and sessions
|
||||
|
||||
`revokeSessionsOnPasswordReset` is enabled: completing a reset deletes **every** session for that
|
||||
person, including the browser that performed it, so the completion screen returns to sign-in.
|
||||
Recovery is what someone uses when they think their credential is compromised, and leaving the
|
||||
attacker signed in would make it cosmetic.
|
||||
|
||||
Managed device sessions are untouched — a device holds a separate credential digest, no email, and no
|
||||
password.
|
||||
|
||||
### Resending
|
||||
|
||||
A sign-in attempt against an unverified account resends the message (`sendOnSignIn`). That path costs
|
||||
nothing to secure because it already required the correct password.
|
||||
`POST /api/auth/send-verification-email` is also available and is enumeration-safe on its own.
|
||||
|
||||
Better Auth rate-limits both mail-triggering paths at **3 requests per 60 seconds per IP**, and
|
||||
sign-in at 3 per 10 seconds. That limiter is **in-memory and per process** — a real control for a
|
||||
single deployment, and deliberately not claimed to be a distributed one.
|
||||
|
||||
### Without a configured relay
|
||||
|
||||
The platform starts, serves, and signs its bootstrap owner in. It configures the identical
|
||||
verification policy and simply cannot deliver. Absent mail does **not** relax
|
||||
`requireEmailVerification`, which is exactly why the initial owner is created already verified.
|
||||
|
||||
## Creating the first platform owner
|
||||
|
||||
@@ -325,6 +387,36 @@ DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email owne
|
||||
`--name` is optional and defaults to the email address; the identity model requires a display name,
|
||||
and asking for one that nobody can edit yet would be a question without a purpose.
|
||||
|
||||
### This one account is created already verified
|
||||
|
||||
Every other human account must confirm 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, and may not yet have
|
||||
a domain to send from. Requiring an email round trip here would produce a working installation whose
|
||||
only administrator can never sign in to configure the mail service the requirement depends on
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
|
||||
|
||||
**The command has no mail prerequisite of any kind.** It marks Better Auth's own `emailVerified`
|
||||
column — so the row is indistinguishable from one that verified normally — before granting ownership;
|
||||
if that write fails, the grant and the one-time sentinel roll back together and the command remains
|
||||
available. An adopted identity is marked verified too, because the alternative is bootstrap
|
||||
succeeding and producing an owner who cannot sign in.
|
||||
|
||||
The exception is narrow: exactly one address, exactly once per installation. Its authority is
|
||||
possession of the database, not possession of a mailbox. An account created any other way is still
|
||||
subject to the ordinary policy.
|
||||
|
||||
Existing installations are covered by migration `0007_verified_bootstrap_owners`, which marks
|
||||
currently-granted platform owners verified so enabling this policy cannot lock an operator out of a
|
||||
platform they already run. It touches no other account.
|
||||
|
||||
### How future human accounts will become verified
|
||||
|
||||
There is no user administration and no invitation flow yet, and this documentation does not claim
|
||||
either exists. When one is built, the path is already in place: an account created by any trusted
|
||||
server-side means starts unverified, its first sign-in attempt sends a verification message, and it
|
||||
becomes ordinary once the address is confirmed. The bootstrap owner remains the only exception.
|
||||
|
||||
### The password never becomes an argument or a variable
|
||||
|
||||
It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an
|
||||
|
||||
@@ -37,7 +37,10 @@ the root verification command discovers them automatically.
|
||||
|
||||
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
|
||||
missing or empty modules directory is valid and reports zero installed modules. Installed modules
|
||||
must produce the compiled JavaScript entrypoints declared by their static manifests. Optional
|
||||
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
|
||||
declared browser contributions are listed with their target capability and range, and marked
|
||||
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
|
||||
silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional
|
||||
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
|
||||
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
|
||||
|
||||
@@ -82,7 +85,8 @@ not a broken deployment).
|
||||
Doctor reports a fixed, deterministic set of checks:
|
||||
|
||||
- The active Node.js version against the repository's declared engine range.
|
||||
- Static module discovery and dependency-first ordering.
|
||||
- Static module discovery and dependency-first ordering. A module that declares browser
|
||||
contributions also reports how many of them have a compatible provider among the installed set.
|
||||
- Schema discovery, including validated module and table counts. This imports compiled schema
|
||||
definitions, which the module contract requires to perform no configuration reads, I/O, or
|
||||
database connection.
|
||||
@@ -99,7 +103,10 @@ Doctor reports a fixed, deterministic set of checks:
|
||||
logical bucket are reported separately, because binding is not health.
|
||||
- Outbound email: skipped when no `EKKLESIA_SMTP_HOST` is set, a blocking failure when the
|
||||
`EKKLESIA_SMTP_*` settings are structurally invalid, and otherwise a connection that authenticates
|
||||
where credentials are set and **sends nothing**.
|
||||
where credentials are set and **sends nothing**. That probe performs the same `EHLO` greeting the
|
||||
runtime performs, so a relay that refuses the greeting is reported here rather than discovered when
|
||||
a message silently fails to arrive — see
|
||||
[Outbound email](email.md#when-a-relay-refuses-the-connection).
|
||||
|
||||
No check prints a credential: not the storage access or secret key, not the SMTP password, not the
|
||||
identity secret, not a module secret value.
|
||||
@@ -166,7 +173,7 @@ Object storage
|
||||
|
||||
Outbound email
|
||||
○ Outbound email not configured
|
||||
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.
|
||||
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
|
||||
```
|
||||
|
||||
@@ -189,16 +189,19 @@ while the module retains the original response. Only anonymous unmounts the modu
|
||||
authenticated preserves the session and mounted module. A stale response from an already-unmounted
|
||||
frame is ignored, and network/server failures do not fabricate logout.
|
||||
|
||||
The flow remains at the current Dashboard URL and accepts no callback/return target, so this pass
|
||||
adds no open-redirect input. It exposes no signup or recovery link: production signup is prohibited
|
||||
and no email delivery/recovery system exists. The existing cookie stays host-only; exact trusted
|
||||
The flow remains at the current Dashboard URL and accepts no callback/return target, so it adds no
|
||||
open-redirect input. It exposes **no signup** link, because production signup is prohibited. It does
|
||||
expose a recovery link, and the screens it leads to still accept no destination: the emailed link's
|
||||
landing is parsed by a pure function that recognizes exactly two Ekklesia-owned values and names no
|
||||
URL at all, and the link itself was built by the server from validated configuration rather than
|
||||
from anything a request supplied (ADR 0038). The existing cookie stays host-only; exact trusted
|
||||
origins, credentialed CORS, and explicitly enabled Better Auth origin/CSRF checks remain the
|
||||
boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a
|
||||
separate deployment and threat-model decision.
|
||||
|
||||
### A display page is authorized; its artifact bytes are not authority
|
||||
|
||||
Manifest version 6 binds a display artifact to one permission declared by the same module
|
||||
The manifest binds a display artifact to one permission declared by the same module
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
|
||||
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
|
||||
and public/device identity is not approximated with an anonymous exception. Both the stable
|
||||
@@ -611,6 +614,10 @@ becoming a liability:
|
||||
disables it.
|
||||
- **The password is a deployment secret.** Never in PostgreSQL, never in module configuration, never
|
||||
in an API response, never in Doctor output, never in an error, and no value in `.env.example`.
|
||||
- **The SMTP client identity is not a secret.** `EKKLESIA_SMTP_HELO_NAME` is announced in the clear
|
||||
in `EHLO` on every connection, so it is shown in Doctor output where an operator can compare it
|
||||
with what the relay expects. It is validated as a fully qualified domain name — no scheme, port,
|
||||
brackets, whitespace, or control characters — so nothing arbitrary reaches the wire.
|
||||
- **Nothing is logged.** The transport logs nothing at all. Transactional mail carries verification
|
||||
and reset links, which are exactly the credentials those flows depend on.
|
||||
- **No blind retry.** One bounded attempt. A failure after the message data has been sent is
|
||||
@@ -620,6 +627,40 @@ Deliverability — SPF, DKIM, DMARC, and the relay's own authorization policy
|
||||
responsibility this repository cannot verify, and Doctor says so rather than implying a successful
|
||||
connection means mail arrives.
|
||||
|
||||
### Email verification and password recovery
|
||||
|
||||
Both flows are Better Auth's, delivered over that transport
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
|
||||
|
||||
- **A verified address is a precondition for a session.** An unverified sign-in fails after the
|
||||
password check and before any session exists, so a correct password on an unverified address is
|
||||
worth exactly as much as no password. There is no "partly authenticated" principal to misread.
|
||||
- **The initial platform owner is the one exception**, created already verified by an operator
|
||||
command whose authority is possession of the database. It is exactly one address, exactly once per
|
||||
installation, with no mail prerequisite — the alternative is an installation nobody can ever sign
|
||||
in to.
|
||||
- **Ekklesia stores no token.** No token table, no token hashing, no expiry clock, no consumption
|
||||
logic. Verification tokens are signed JWTs valid for one hour and replayable but idempotent (mail
|
||||
scanners dereference links); reset tokens are stored rows, valid for one hour, and consumed.
|
||||
- **Following a link never creates a session.** `autoSignInAfterVerification` is off, so a URL
|
||||
fetched by a link scanner, a prefetcher, or a mail gateway hands out nothing.
|
||||
- **Links are built from configuration.** Only the token is taken from the identity system; the
|
||||
destination comes from a validated Dashboard origin that must be canonical, https and non-loopback
|
||||
in production, and a trusted origin — checked at startup. No request parameter can steer where a
|
||||
recipient lands.
|
||||
- **Tokens never reach a log, an error, or a diagnostic**, and the browser strips the reset token
|
||||
from the address bar with `history.replaceState` before rendering the form. It is never written to
|
||||
`localStorage`, `sessionStorage`, or a cookie.
|
||||
- **Recovery is enumeration-safe, including when mail is broken.** Every user-facing answer is
|
||||
identical for an address with an account, without one, already verified, or undeliverable. The
|
||||
delivery adapter never throws, because the identity system rethrows what its callback throws and
|
||||
that would turn a failed relay into an account oracle. Failures are reported out of band with a
|
||||
sanitized code and no address, token, or link.
|
||||
- **Password reset revokes that person's sessions**, including the one performing it. Managed device
|
||||
sessions are untouched: a device holds a separate credential digest, no email, and no password.
|
||||
- **Verification does not imply registration.** Public signup remains disabled and the browser offers
|
||||
no way to ask for an account.
|
||||
|
||||
### Rate limiting is per-process today
|
||||
|
||||
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
||||
@@ -628,6 +669,11 @@ only while **both** of the following hold, which is the current state:
|
||||
- production public signup is disabled (and cannot be enabled — see above); and
|
||||
- exactly one API instance serves traffic.
|
||||
|
||||
The same limiter covers the two paths that trigger an email — password-reset requests and
|
||||
verification resends — at **3 requests per 60 seconds per IP**. That is a real control against
|
||||
turning the platform into someone's mail cannon from one source, and it is deliberately **not**
|
||||
claimed to be distributed: a multi-process deployment gets that budget per process.
|
||||
|
||||
If either changes — public signup is enabled, or a second instance is added — a durable shared or
|
||||
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
|
||||
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.
|
||||
|
||||
@@ -69,6 +69,77 @@ protocol-relative, backslash-prefixed, and scheme-bearing values cannot become n
|
||||
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
|
||||
of the separate display bundle.
|
||||
|
||||
Phase 8 adds evidence for the five platform prerequisites.
|
||||
|
||||
**Design tokens and shared elements** (ADR 0039). The token registry's tests prove it is
|
||||
deterministic and enumerable, that every token derives a unique `--ekklesia-*` identifier from its
|
||||
name and carries a label, description, and category, that each status family pairs a colour with a
|
||||
non-colour symbol, and that a duplicate name, an unknown category, an invalid or single-segment name,
|
||||
and a value carrying a declaration terminator, a rule block, a comment, an at-rule, `url(`,
|
||||
`!important`, an escape, a control character, or markup are each rejected — with an error that never
|
||||
echoes the rejected declaration. The elements' tests assert accessible structure (label/hint/error
|
||||
association, icon-button naming, tab roles and arrow-key movement, announced error and loading
|
||||
states), that status never depends on colour alone, that a dialog renders in place rather than
|
||||
portalling out of an isolated root, and — mechanically — that the stylesheet and every component
|
||||
source contain no literal colour, font stack, or theme value and reference only registered tokens.
|
||||
Both packages compile module-shaped source under the exact browser project a module uses, where
|
||||
`process.env` must fail to typecheck, so the browser boundary is measured rather than asserted.
|
||||
|
||||
**Module stylesheets and artifact isolation** (ADR 0040). The builder's tests prove a plain
|
||||
stylesheet is compiled into the declared artifact with no `.css` emitted beside it, that a local
|
||||
`@import` is inlined, and that an unresolved `@import`, a stylesheet declaring an `--ekklesia-*`
|
||||
variable, and a non-CSS dialect are each rejected with their own sanitized code — while a private
|
||||
alias derived from a registered token is accepted. The loader's tests prove every artifact mounts
|
||||
inside an open shadow root, that the artifact receives that root rather than the host's element, that
|
||||
exactly one `<style>` is installed per mount and never accumulates across remounts, that the host's
|
||||
base rules precede the artifact's so a display can override `:host`, that unmount removes the whole
|
||||
root even when the module's cleanup throws, that a failed mount leaves neither DOM nor styles behind,
|
||||
that an environment without Shadow DOM fails closed rather than mounting unisolated, and that an
|
||||
artifact built against the previous ABI is refused with `MODULE_UI_ABI_UNSUPPORTED`. The template's
|
||||
suite builds real React artifacts through the canonical pipeline and asserts the compiled stylesheet
|
||||
travels inside the one declared file.
|
||||
|
||||
**Browser contributions** (ADR 0041). Manifest tests cover namespacing, the single local segment,
|
||||
duplicate ids and artifact paths, collision with an entrypoint, and a contribution targeting a
|
||||
capability its own module provides. Discovery tests prove a contribution artifact inherits the exact
|
||||
path, symlink, containment, extension, and file-kind protections every other compiled entrypoint has,
|
||||
including two contributions linked to one file. Publication tests prove a contribution is published
|
||||
only when another eligible module provides its capability *within the declared range*, that the
|
||||
contributing module never sees its own contribution, that a lookup fails closed on an unknown,
|
||||
mismatched, or non-string identity, and that the published record carries no domain vocabulary at
|
||||
all. Route tests prove the artifact is served byte-for-byte at a URL that cannot select a neighbour,
|
||||
and that an unknown id, another module's id, a traversal, a source map, a wrong version, an absent
|
||||
provider, and an incompatible provider all collapse into one indistinguishable `404`. The loader's
|
||||
tests cover mount, update-without-remount, a renderer that declares no `update`, deterministic and
|
||||
idempotent unmount, a sanitized update failure, and input that is not genuinely JSON — a `Date`, a
|
||||
function, `NaN`, `undefined`, and a cycle — refused *before* anything is imported and without ever
|
||||
echoing the consumer's data. The flagship shell fixture drives the whole relationship: a `signage`
|
||||
module discovers, mounts, updates, and unmounts an `announcements` React renderer with neither module
|
||||
importing the other, the consumer seeing identity but never a URL, the host resolving publication,
|
||||
and a contribution id the server withheld refused without a fetch.
|
||||
|
||||
**Bounded streaming uploads** (ADR 0042). Contract tests pin the two ceilings, that `maxBodyBytes` is
|
||||
checked against the ceiling the declared mode selects, that 32 MiB holds a 25 MiB image and one byte
|
||||
more is refused, and that no mode admits an unbounded body. HTTP tests cover exact-boundary success,
|
||||
a declared `Content-Length` refused before the handler runs, a misleading smaller `Content-Length`
|
||||
that cannot smuggle a megabyte past the limit, a chunked overflow, a `413` that still wins when the
|
||||
handler swallowed the stream error, proof that the body is genuinely not buffered (a handler sees the
|
||||
first chunk before the last is produced), an aborted upload surfacing as an ordinary failure rather
|
||||
than a `413`, an unread body being released, authorization ordering in both directions, and a
|
||||
rejection that names no limit, size, or body content. The live suite uploads a real 25 MiB body over
|
||||
a real socket end to end and proves the server is healthy immediately afterwards.
|
||||
|
||||
**Display offline fallback** (ADR 0043). The worker's caching logic is exercised directly, because it
|
||||
touches no global: first online load, offline reload after one successful visit, a missing uncached
|
||||
artifact failing honestly, contributed-renderer fallback, version upgrade with pruning that keeps
|
||||
every artifact of the current version and never touches another module's, superseded cache
|
||||
generations removed while unrelated ones survive, network-first behaviour picking up rebuilt bytes,
|
||||
and an explicit inventory proving the descriptor, principal, module API, realtime stream, pairing
|
||||
exchange, auth routes, cross-origin signed object URLs, and the worker itself are **never** stored —
|
||||
so a revoked device's refusal is what a reconnected display sees. Page-level tests cover the quiet
|
||||
offline indicator, a cache fallback reported while the browser still claims to be online, automatic
|
||||
re-resolution when the network returns, and a revoked device dropping back to pairing on reconnect.
|
||||
|
||||
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
|
||||
database.
|
||||
|
||||
@@ -271,6 +342,53 @@ demonstrate what it *cannot* say — its whole world is a `ModuleStorage` and tw
|
||||
manifest — and it asserts that the module's durable state holds keys while only the URL carries
|
||||
host detail. It also proves that forty URLs cost no provider round trips.
|
||||
|
||||
A ninth suite (`apps/api/tests/email-verification.integration.ts`, same harness) proves email
|
||||
verification and password recovery **end to end**
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Everything
|
||||
in it is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better Auth endpoints,
|
||||
the actual SMTP provider, and an in-process `smtp-server` accepting the message. The verification and
|
||||
reset links are **extracted from the delivered message** and then followed verbatim — the API is
|
||||
bound to a port reserved before it is configured, precisely so the emailed URL is the one the test
|
||||
resolves rather than one it had to rewrite. A fake mail function would have proved that a callback
|
||||
ran and nothing about whether the resulting link works.
|
||||
|
||||
It pins: an unverified account refused a session with `403 EMAIL_NOT_VERIFIED` and no cookie; the
|
||||
full unverified → emailed link → verified → signed in path; a malformed and an expired token
|
||||
reported as separate reasons on the host's own landing; verification replay as an idempotent no-op
|
||||
that mints nothing; a reset link that changes the password, invalidates the old one, is consumed on
|
||||
use, and signs every existing session out; the identity system's own 8–128 policy enforced at reset
|
||||
with no reset-specific rule; an unknown address answered byte-identically to a real one; a
|
||||
mail-less installation answering identically while recording `MAIL_NOT_CONFIGURED` out of band;
|
||||
attacker-supplied `redirectTo`/`callbackURL` values refused, and a *trusted* one still ignored
|
||||
because the link is rebuilt from configuration; public signup still refused; and a paired device row
|
||||
untouched across a human password reset.
|
||||
|
||||
The expired-token case is the one place any test constructs a token rather than reading one from a
|
||||
delivered message: the verification token is a plain HS256 JWS over the auth secret this suite
|
||||
configured, so an already-expired one is forged from that secret with `node:crypto` rather than by
|
||||
waiting an hour.
|
||||
|
||||
**Nothing here reaches the internet and no live credential exists anywhere in it.**
|
||||
|
||||
`apps/api/tests/initial-owner.integration.ts` additionally proves the trusted-bootstrap exception:
|
||||
that the owner is created verified (asserted in the database *and* through a real browser sign-in),
|
||||
that neither bootstrap nor sign-in needs any mail configuration, that an adopted identity is marked
|
||||
verified too, and that the exception is narrow — an ordinary account created by the identical trusted
|
||||
creator stays unverified and is refused.
|
||||
|
||||
Browser-side, `surfaces/shell/tests/auth-recovery.test.tsx` proves the host-owned screens: that an
|
||||
unverified sign-in offers verification rather than a credentials error and never renders the
|
||||
Dashboard, that recovery shows one generic completion state whatever the address, that a repeated
|
||||
submit sends one email, that a mismatched confirmation is blocked before anything is sent, and that a
|
||||
reset token never reaches `localStorage`, `sessionStorage`, the DOM, or the address bar. The landing
|
||||
parser is exercised as the pure function it is, including that it names no destination at all.
|
||||
|
||||
`core/tests/identity-verification-boundaries.test.ts` reads the repository itself for the properties
|
||||
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
|
||||
schema gained a token column and no identity code hashes or compares a token, that nothing
|
||||
interpolates a token, link, address, or body into a log line, that a device has no email, password,
|
||||
or verification concept, and that no browser source imports Better Auth or Core's mail.
|
||||
|
||||
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
|
||||
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
||||
binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy
|
||||
|
||||
@@ -73,7 +73,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 6,
|
||||
"manifestVersion": 7,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -122,6 +122,16 @@ without executing module code:
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"id": "calendar.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -140,12 +150,13 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 5 manifest, and any other version, fails validation outright rather than being silently
|
||||
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 6 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
|
||||
changes only the version literal; `ui` remains independently optional
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Version 7 adds the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||
contributes nothing changes only the version literal; every browser artifact remains independently
|
||||
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
@@ -214,6 +225,16 @@ are separate artifacts because a full-viewport display is not a Dashboard screen
|
||||
|
||||
```json
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"id": "calendar.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -271,7 +292,7 @@ artifact is the role selection.
|
||||
|
||||
### What the host gives you
|
||||
|
||||
`mount` receives exactly five members, and nothing else is available:
|
||||
`mount` receives exactly six members, and nothing else is available:
|
||||
|
||||
| Member | What it is |
|
||||
| --- | --- |
|
||||
@@ -280,10 +301,17 @@ artifact is the role selection.
|
||||
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
|
||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
|
||||
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
|
||||
|
||||
Navigation, host theme and design tokens, module configuration, organization context, and
|
||||
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
|
||||
speculative field frozen into a public contract is harder to remove than to add.
|
||||
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
|
||||
contract. They need designs that do not exist, and a speculative field frozen into a public contract
|
||||
is harder to remove than to add.
|
||||
|
||||
The host **theme** is deliberately not a context member either, and that is not a gap: design tokens
|
||||
reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import
|
||||
`@ekklesia/design-tokens` and write `var(--ekklesia-…)`; the host installs the values at its own
|
||||
root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that
|
||||
can forget to pass one.
|
||||
|
||||
### Never build your own API URL
|
||||
|
||||
@@ -413,20 +441,157 @@ loads several React copies, and artifacts are large (the template's is roughly 5
|
||||
an operator can review it). That is accepted for now and can be optimized later **without changing
|
||||
the contract**, because the contract is a mount function rather than a component.
|
||||
|
||||
### Styling: inline style objects only, for now
|
||||
### Design tokens and shared elements
|
||||
|
||||
Style with React's `style` prop:
|
||||
Your UI may import two shared design packages, and only those two
|
||||
([ADR 0039](../architecture/decisions/0039-shared-design-tokens-and-elements.md)):
|
||||
|
||||
| Package | What it is |
|
||||
| --- | --- |
|
||||
| `@ekklesia/design-tokens` | The platform's global semantic theme — a validated, enumerable registry of named values and the `var(--ekklesia-…)` references for them |
|
||||
| `@ekklesia/ui` | The foundational Ekklesia elements, every one of them built from those tokens |
|
||||
|
||||
```tsx
|
||||
<section style={{ display: "grid", gap: "0.5rem" }}>…</section>
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { Badge, Button, EkklesiaUiStyles, Panel } from "@ekklesia/ui";
|
||||
|
||||
export function Slides({ context }: { readonly context: ModuleUiHostContext }) {
|
||||
return (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<Panel actions={<Button variant="primary">Add slide</Button>} title="Slides">
|
||||
<p style={{ color: designToken("text.secondary") }}>Nothing scheduled yet.</p>
|
||||
<Badge tone="warning">Paused</Badge>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
|
||||
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no
|
||||
manifest field declares it and no host loads it — so it would work in your tests and silently do
|
||||
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
|
||||
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
|
||||
this phase.
|
||||
Render `<EkklesiaUiStyles />` once, near the top of your tree. It puts the element stylesheet into
|
||||
whichever root your artifact is mounted in, so you never have to know whether that is a document or
|
||||
an isolated root.
|
||||
|
||||
**Use semantic names, not colours.** `designToken("text.danger")` says what the text *means*;
|
||||
`#9f1239` says what it looks like today. An installation that changes its theme changes the first
|
||||
and not the second. `designToken` only accepts registered names, so a typo is a compile error.
|
||||
|
||||
**Never define an `--ekklesia-*` custom property.** Those are the platform's global theme variables,
|
||||
and inventing one would create a global a future theme editor could not enumerate and another module
|
||||
could unknowingly depend on. You may derive as many *private* aliases as you like inside your own
|
||||
artifact, under your own prefix:
|
||||
|
||||
```tsx
|
||||
<div style={{ "--calendar-row-gap": designToken("spacing.sm") } as CSSProperties}>
|
||||
```
|
||||
|
||||
The host installs the default token values at its own root, and CSS custom properties inherit
|
||||
through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at
|
||||
run time and nothing for the host to hand you.
|
||||
|
||||
Every other shared package stays rejected inside a module: `@ekklesia/api-client`,
|
||||
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
||||
import, and `pnpm boundaries` says so.
|
||||
|
||||
### Styling: a stylesheet compiled into your artifact
|
||||
|
||||
Author plain `.css` beside your UI source, import it, and declare it
|
||||
([ADR 0040](../architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)):
|
||||
|
||||
```text
|
||||
src/ui/index.tsx
|
||||
src/ui/styles.css
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||
import styles from "./styles.css";
|
||||
|
||||
export default defineModuleUi({
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${styles}`,
|
||||
mount(container, context) { /* … */ },
|
||||
});
|
||||
```
|
||||
|
||||
TypeScript needs to know what a `.css` import is. Copy the template's `src/module-styles.d.ts` into
|
||||
your module and include it in your UI TypeScript project:
|
||||
|
||||
```ts
|
||||
declare module "*.css" {
|
||||
const stylesheet: string;
|
||||
export default stylesheet;
|
||||
}
|
||||
```
|
||||
|
||||
**There is no sidecar file.** `@ekklesia/module-ui-build` compiles each stylesheet — resolving local
|
||||
`@import`s — into your declared JavaScript artifact as text. One declared file still contains
|
||||
everything, so there is nothing extra to publish, nothing for a host to fetch, and no way for your
|
||||
CSS to become an asset nobody serves. That was the exact hazard that kept stylesheets out until now.
|
||||
|
||||
Two rules are enforced by the build, each with its own error:
|
||||
|
||||
- **Reference Ekklesia's global theme variables; never declare one.** `--ekklesia-*` belongs to the
|
||||
registry a future theme editor enumerates (ADR 0039). Derive private aliases under your own prefix
|
||||
freely: `--announcements-slide-gap: var(--ekklesia-spacing-sm)`.
|
||||
- **Every `@import` must resolve to a local file.** A remote import would make your artifact fetch a
|
||||
stylesheet at run time, and a declared artifact has to be self-contained.
|
||||
|
||||
Plain `.css` only. `.scss`, `.less`, and other dialects stay rejected — adding a preprocessor is a
|
||||
toolchain decision that needs its own ADR. A CSS `url()` pointing at a local image or font also fails
|
||||
the build: no asset loader is configured. Use a data URL, or serve real media from your module's
|
||||
object storage.
|
||||
|
||||
`pnpm boundaries` allows `.css` inside `src/ui/` and `src/display/` only. A stylesheet anywhere else
|
||||
in your module is a file no build reads and no host loads.
|
||||
|
||||
### Your artifact renders in its own isolated root
|
||||
|
||||
Every browser artifact — primary UI, display UI, and any renderer you contribute — is mounted inside
|
||||
a Shadow DOM root the host creates, with your stylesheet installed in it:
|
||||
|
||||
```text
|
||||
host container
|
||||
└─ <div data-ekklesia-module-ui>
|
||||
#shadow-root (open)
|
||||
├─ <style> host base rules, then your declared CSS
|
||||
└─ <div> ← the container your mount() receives
|
||||
```
|
||||
|
||||
What that buys you, and what it costs:
|
||||
|
||||
- **Your selectors cannot leak out**, and the Dashboard's cannot leak in. Write `.slide` without a
|
||||
prefix and without worrying about another module's `.slide`.
|
||||
- **Design tokens still reach you.** CSS custom properties inherit through the boundary, so
|
||||
`var(--ekklesia-…)` resolves from the values the host installed at its root. Nothing to import at
|
||||
run time, nothing for the host to pass.
|
||||
- **Two artifacts of your own module are isolated from each other too.** Your management UI and your
|
||||
display share styles only if they share a source file.
|
||||
- **Your DOM is not in the host document's tree.** `document.querySelector` from outside will not
|
||||
find your elements — including in your own tests, which should mount the artifact and query inside
|
||||
the root the loader hands `mount`.
|
||||
- **You get no box by default.** The host's two wrapper elements are `display: contents`, so you lay
|
||||
out exactly where the host put you. A full-viewport display that needs a real box overrides it:
|
||||
`:host { display: block }`, which works because the host installs its base rules *before* yours.
|
||||
- **Mount and unmount are deterministic.** The host installs your stylesheet once per mount and
|
||||
removes the whole root on unmount, so repeated mounts never accumulate styles and a cleanup that
|
||||
throws still cannot leave your CSS behind. You never install or remove your own `<style>`.
|
||||
|
||||
**This is not a sandbox.** Your artifact is trusted first-party code in the host's own JavaScript
|
||||
realm and can reach `document` if it wants to; the root is `open` for exactly that reason. It is a
|
||||
correctness boundary for styles, and nothing more. See
|
||||
[Security model and deployment trust boundaries](../development/security.md).
|
||||
|
||||
### The mount ABI is versioned
|
||||
|
||||
`defineModuleUi` stamps the browser mount ABI version onto your artifact — you never write it. A host
|
||||
verifies it before mounting, and an artifact built against an older SDK fails with
|
||||
`MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics.
|
||||
|
||||
Migrating from ABI 1 is one `pnpm build`. If you declare no stylesheet, no source change is needed at
|
||||
all; if your own tests assert on module DOM, they need to look inside the isolated root.
|
||||
|
||||
### What you may and may not use today
|
||||
|
||||
@@ -435,9 +600,9 @@ the build pipeline** — adding the pipeline required no boundary relaxation at
|
||||
|
||||
| Allowed in a module | Still rejected |
|
||||
| --- | --- |
|
||||
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
||||
| `.tsx` source, and plain `.css` inside `src/ui/` or `src/display/` | `.jsx` (strict TypeScript only), `.css` outside a browser source root, `.scss`/`.less`/`.styl`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
||||
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
|
||||
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build` | `@ekklesia/ui` and other shared UI-role packages |
|
||||
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build`, `@ekklesia/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` |
|
||||
|
||||
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
|
||||
`@ekklesia/module-ui-build` owns the bundler entirely.
|
||||
@@ -523,8 +688,9 @@ Four things about it matter to you as a module author:
|
||||
- **Only the artifact that role declares is served.** The module id and version in either URL are
|
||||
lookup keys, never path components — there is no way to request a second file from your package.
|
||||
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
||||
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
|
||||
stylesheet import still fails the build.
|
||||
an asset, it must be inside the bundle: that is exactly why your stylesheet is compiled into the
|
||||
artifact as text rather than published beside it (ADR 0040), and why a bundled image or font is
|
||||
still not supported.
|
||||
- **The version must match exactly.** A request for any other version is refused rather than served
|
||||
your installed artifact, so a stale host cannot mount something it did not ask for.
|
||||
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
|
||||
@@ -580,7 +746,7 @@ eligible, running, and declares `entrypoints.display`, Ekklesia registers exactl
|
||||
|
||||
The page and descriptor both require the manifest's display permission through the ordinary Core
|
||||
authorization evaluator and an authenticated cookie session. A `public` display permission is
|
||||
rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are
|
||||
rejected; there is no anonymous exception or device token. Artifact bytes are
|
||||
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
|
||||
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
|
||||
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
|
||||
@@ -598,8 +764,31 @@ artifact remains compatible with a future standalone host.
|
||||
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
|
||||
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
|
||||
feature experience and cleanup inside its container. There is one display per module. Display
|
||||
inventory/management, named variants, targeting, public/device identity, a standalone host, shared
|
||||
CSS, Announcements migration, and Companion integration are deferred.
|
||||
inventory/management, named variants, targeting, public/device identity, a standalone host,
|
||||
Announcements migration, and Companion integration are deferred.
|
||||
|
||||
### Your display survives a network outage — as code, not as content
|
||||
|
||||
A display page is controlled by a service worker that caches **the shell and the declared code**: the
|
||||
page, the host script, your display artifact, and every contributed renderer that display actually
|
||||
mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is
|
||||
network-first, so a screen picks up a new build the moment the server returns and can never be
|
||||
trapped on an obsolete one.
|
||||
|
||||
What that means for you, concretely:
|
||||
|
||||
- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the
|
||||
loader still verifies the artifact's declared id and version, so a fallback can never mount
|
||||
something the descriptor did not name.
|
||||
- **Your display cannot render while the network is down.** Mounting requires the descriptor, the
|
||||
descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked
|
||||
stays revoked, and cached code can never manufacture authorization it no longer has. The screen
|
||||
shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity
|
||||
returns.
|
||||
- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and
|
||||
every signed object-delivery URL are deliberately never stored. If you want a screen to keep
|
||||
showing content through an outage, persisting that content is *your module's* job — this is the
|
||||
foundation it would build on, not a substitute for it.
|
||||
|
||||
### What a module UI will never own
|
||||
|
||||
@@ -626,6 +815,132 @@ A Dashboard may later group modules into folders (`Media` holding Video and Live
|
||||
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
||||
declared in your manifest, and your module must never require one to work correctly.
|
||||
|
||||
## Browser contributions
|
||||
|
||||
Sometimes another module owns a screen, and *you* own what belongs on part of it. A signage module
|
||||
owns a timeline; an announcements module knows what an announcement should look like. Neither can
|
||||
import the other — modules are independently installed, independently versioned peers — and neither
|
||||
should re-implement the other's job.
|
||||
|
||||
A **browser contribution** is the mechanism for exactly that
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)): a compiled renderer you
|
||||
declare for a capability *another* module provides, which that module mounts inside a container it
|
||||
owns.
|
||||
|
||||
```text
|
||||
signage provides the capability signage.slide-renderer, and publishes its input contract
|
||||
announcements contributes a React renderer for it
|
||||
signage mounts that renderer in one timeline slot, updates it, and takes it down
|
||||
```
|
||||
|
||||
### Contributing a renderer
|
||||
|
||||
Declare it in your manifest and put the source at the fixed path your contribution id implies:
|
||||
|
||||
```json
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"id": "announcements.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
src/contributions/signage-slide/index.tsx → the contribution's compiled artifact
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import styles from "./styles.css";
|
||||
import { Slide, type SlideInput } from "./Slide.js";
|
||||
|
||||
export default defineModuleContribution({
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
styles,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<Slide input={context.input as SlideInput} />);
|
||||
return {
|
||||
update(input) {
|
||||
root.render(<Slide input={input as SlideInput} />);
|
||||
},
|
||||
unmount() {
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The id is `<your-module-id>.<local-name>`, which is what makes it unique platform-wide without a
|
||||
registry. Your contribution carries **its own version**, independent of your module's, and the
|
||||
platform verifies the artifact claims it before mounting — a stale bundle is caught rather than
|
||||
rendered. `capabilityRange` says which provider versions you work with: if no installed module
|
||||
provides that capability inside your range, your contribution is simply not published. It is never
|
||||
mounted and broken.
|
||||
|
||||
`pnpm build` compiles it alongside your other artifacts. You configure no paths and no bundler.
|
||||
|
||||
### What a contributed renderer receives
|
||||
|
||||
Two things: its own id, and the consumer's `input`.
|
||||
|
||||
That is the whole context. **No API client, no realtime subscription, no principal, no permissions,
|
||||
no configuration, no database, no storage, and no credential.** Mounting your renderer grants your
|
||||
module nothing — server-side authorization is untouched, and everything the renderer needs arrives
|
||||
through the consumer's input, because the consumer is the party that was authorized to fetch it.
|
||||
|
||||
The input is **opaque JSON to the platform**. Ekklesia validates that it is genuinely
|
||||
JSON-serializable and nothing else: not its shape, not its meaning, not its version. The consuming
|
||||
module publishes that contract alongside its capability and versions it through its capability
|
||||
version. Validate what you receive and render a sensible default when it is not what you expected —
|
||||
you may be running against an older or newer consumer than the one you developed against.
|
||||
|
||||
`update` is what lets a long-running screen change content without restarting animations or reloading
|
||||
media on every tick. Implement it if you can render new input in place; omit it if you genuinely
|
||||
cannot, and the consumer will unmount and mount you again.
|
||||
|
||||
Your renderer mounts in its own isolated root with its own stylesheet, exactly like a module UI
|
||||
artifact (ADR 0040): your rules cannot reach the consuming module's screen around you, and its rules
|
||||
cannot reach in.
|
||||
|
||||
### Consuming contributions
|
||||
|
||||
If *your* module provides a capability other modules can render for, ask the host what was
|
||||
contributed:
|
||||
|
||||
```tsx
|
||||
const renderers = context.contributions.list("signage.slide-renderer");
|
||||
|
||||
const slide = await context.contributions.mount({
|
||||
container: slotElement, // an element your module owns
|
||||
contributionId: renderers[0].id,
|
||||
input: { headline, seconds }, // your own published contract
|
||||
});
|
||||
|
||||
await slide.update({ headline: next, seconds });
|
||||
await slide.unmount();
|
||||
```
|
||||
|
||||
You see identity, version, and capability — never a URL. Asking the host to mount by id is what keeps
|
||||
your module hostable somewhere other than Ekklesia, exactly as `context.api.request("records")` does.
|
||||
|
||||
You will only ever see contributions aimed at a capability **your** module provides, and only ones
|
||||
the server judged compatible. Handle an empty list: a contributor may be absent, disabled, or outside
|
||||
your compatible range, and that is ordinary rather than exceptional.
|
||||
|
||||
You own the input contract completely. Publish it beside your capability, version it with your
|
||||
capability version, and remember that a renderer written against an older version of it may still be
|
||||
installed.
|
||||
|
||||
## Runtime server definition
|
||||
|
||||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
||||
@@ -713,8 +1028,8 @@ export default defineModuleRuntime({
|
||||
});
|
||||
```
|
||||
|
||||
A route definition has exactly five fields — `method`, `path`, `permission`, `maxBodyBytes`,
|
||||
`handler` — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
|
||||
A route definition has exactly five required fields — `method`, `path`, `permission`,
|
||||
`maxBodyBytes`, `handler` — plus one optional `bodyMode`, and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
|
||||
middleware, audit metadata, authentication behavior, or another module's namespace) fails
|
||||
registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`,
|
||||
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
|
||||
@@ -779,15 +1094,61 @@ ones. The platform continues to own authentication, authorization, `Origin`, COR
|
||||
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
|
||||
before your handler runs — an unauthorized caller can never reach your validation at all.
|
||||
|
||||
### Request bodies: `maxBodyBytes` and `bodyMode`
|
||||
|
||||
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
|
||||
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
|
||||
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
|
||||
accepted. It must be a non-negative integer no greater than the platform ceiling,
|
||||
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`@ekklesia/contracts`, currently 1 MiB) — a negative,
|
||||
non-integer, non-finite, or over-ceiling value fails registration with
|
||||
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`. The platform enforces this limit itself, ahead of permission
|
||||
evaluation and the handler, using the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}`
|
||||
contract every other body-consuming route in the platform uses — see
|
||||
accepted. A negative, non-integer, non-finite, or over-ceiling value fails registration with
|
||||
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`.
|
||||
|
||||
`bodyMode` chooses how that body reaches you, and therefore which ceiling applies
|
||||
([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||||
|
||||
| `bodyMode` | Ceiling | What the platform does |
|
||||
| --- | --- | --- |
|
||||
| `"buffered"` (default) | 1 MiB | Reads and counts the whole body **before** your handler runs |
|
||||
| `"streamed"` | 32 MiB | Counts bytes **as you consume them**, and never materializes the body |
|
||||
|
||||
```ts
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/slides",
|
||||
permission: "signage.slides.write",
|
||||
maxBodyBytes: 33_554_432,
|
||||
bodyMode: "streamed",
|
||||
async handler(request, context) { /* read request.body as a stream */ },
|
||||
});
|
||||
```
|
||||
|
||||
Omitting `bodyMode` means `buffered`, which is what every route written before this existed meant.
|
||||
|
||||
**Memory.** In `buffered` mode the platform holds at most your declared allowance, which is why that
|
||||
ceiling is small and why `await request.json()` is safe there. In `streamed` mode the platform holds
|
||||
nothing: read `request.body` as a stream. Calling `request.arrayBuffer()` on a streamed route works
|
||||
but throws away the entire point — you have just buffered 32 MiB yourself.
|
||||
|
||||
**Streaming.** A declared `Content-Length` over your limit is refused synchronously, before
|
||||
authorization and before a byte is read. Otherwise the platform counts what actually arrives, so a
|
||||
misleading or absent `Content-Length` bounds nothing.
|
||||
|
||||
**A `413` can arrive after your handler starts.** This is the one consequence of `streamed` mode you
|
||||
must design for. When the running total passes your limit the stream errors, the source is cancelled,
|
||||
and the platform replaces your response with the sanitized `413` — even if you caught the error and
|
||||
answered anyway. If you persist as you read, write to a temporary key and commit only once the read
|
||||
completes; the platform cannot do that for you, because only your module knows what a partial write
|
||||
means to its own data.
|
||||
|
||||
**Timeouts.** Body reading is bounded by the server's own header and request timeouts, not by a
|
||||
per-route setting. A slow upload is a slow request, and the platform has one answer for that.
|
||||
|
||||
**One file per request.** 32 MiB is the platform's absolute ceiling in every mode — there is no
|
||||
unbounded option anywhere in the contract. A UI uploading twenty images issues twenty individually
|
||||
bounded requests; nothing here can express a 500 MiB aggregate upload, deliberately, because one bad
|
||||
byte in one would waste everything already sent.
|
||||
|
||||
Every rejection uses the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}` contract every
|
||||
other body-consuming route in the platform uses — see
|
||||
[API](../development/api.md#request-body-limits).
|
||||
|
||||
`path` is local to the module, never the path a caller actually sends. The platform alone owns the
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./authorization.js";
|
||||
export * from "./identifiers.js";
|
||||
export * from "./identity.js";
|
||||
export * from "./module-configuration.js";
|
||||
export * from "./module-contribution.js";
|
||||
export * from "./module-database.js";
|
||||
export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
import { capabilityIdSchema, packageRelativePathSchema } from "./identifiers.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
/**
|
||||
* A **browser contribution**: one module's compiled renderer, declared for a capability another
|
||||
* module provides, and mounted by that other module inside a container it owns (ADR 0041).
|
||||
*
|
||||
* This is deliberately not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts
|
||||
* *the platform* mounts, in surfaces the platform owns. A contribution is mounted by a **consumer
|
||||
* module**, at a moment and in a place only that module understands — a slot in its own layout —
|
||||
* and the platform never renders one on its own.
|
||||
*
|
||||
* ## What the platform knows, and the complete list of it
|
||||
*
|
||||
* Identity, version, target capability, compatible range, and one compiled artifact. That is all,
|
||||
* and the omissions are the point: nothing here says what the renderer draws, what shape its input
|
||||
* has, when it should be mounted, or what any of it means. Core could not learn what a "slide" or a
|
||||
* "timeline slot" is from this declaration, because the declaration cannot express it — the
|
||||
* domain contract belongs entirely to the consumer's own versioned input.
|
||||
*
|
||||
* Inert, like every other manifest declaration: reading one causes no import, no evaluation, and no
|
||||
* network or database access.
|
||||
*/
|
||||
|
||||
/** `<module-id>.<local-name>` — exactly two segments. The prefix is checked against the declaring
|
||||
* module in `moduleManifestSchema`, which is what makes a contribution id globally unique without a
|
||||
* registry: module ids are already unique, so two modules cannot produce the same contribution id. */
|
||||
export const contributionIdSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(160)
|
||||
.regex(
|
||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
|
||||
"Must be <module-id>.<local-name>, both lowercase kebab-case.",
|
||||
);
|
||||
|
||||
export const moduleContributionDeclarationSchema = z
|
||||
.object({
|
||||
id: contributionIdSchema,
|
||||
/** This contribution's own version, independent of the module's. A consumer sees it and a host
|
||||
* verifies the artifact claims it, so a stale bundle is caught exactly as it is for a module
|
||||
* UI artifact. */
|
||||
version: semanticVersionSchema,
|
||||
/** The capability this renderer plugs into — owned by whichever module provides it, never by
|
||||
* the contributing module. */
|
||||
capability: capabilityIdSchema,
|
||||
/** The provider capability versions this renderer is compatible with. A provider outside this
|
||||
* range makes the contribution unpublishable rather than mounted and broken. */
|
||||
capabilityRange: semanticVersionRangeSchema,
|
||||
/** The compiled browser artifact, resolved and validated during discovery exactly like every
|
||||
* other declared entrypoint: package-relative, inside the package before and after symlink
|
||||
* resolution, and a real `.js`/`.mjs` file. */
|
||||
artifact: packageRelativePathSchema,
|
||||
description: z.string().trim().min(1).max(240).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ModuleContributionDeclaration = z.output<typeof moduleContributionDeclarationSchema>;
|
||||
|
||||
/** The local segment of a contribution id — the part after the declaring module's own id. Used by
|
||||
* the canonical builder to locate the fixed source directory, so a module author configures no
|
||||
* paths and cannot point the build somewhere else. */
|
||||
export function contributionLocalName(contributionId: string): string {
|
||||
return contributionId.slice(contributionId.indexOf(".") + 1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user