26 Commits
Author SHA1 Message Date
The_River d593c10f1d Merge pull request 'docs: propose ADR 0044 reference deployment; fix manifest version' (#44) from docs/adr-0044-reference-deployment into main
CI / verify (push) Successful in 46s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 1m43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/44
2026-10-01 21:27:46 +00:00
ekklesiaandClaude Opus 5.5 06e55cebd5 docs: propose ADR 0044 reference deployment; fix manifest version
CI / verify (pull_request) Successful in 47s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m43s
Add ADR 0044 (Status: Proposed), the single self-hosted reference
deployment composed from pinned sources, and list it in the ADR index.

Correct CLAUDE.md, which described manifest v6; the contracts accept
only manifestVersion 7 (MODULE_MANIFEST_VERSION in
packages/contracts/src/module-manifest.ts).

No code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:08:44 -05:00
The_River f3cd5a4152 Merge pull request 'Fix/signage runtime layout' (#43) from fix/signage-runtime-layout into main
CI / verify (push) Successful in 48s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m46s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/43
2026-10-01 00:00:51 +00:00
ekklesia 3a26ef49ad feat: establish shared Ekklesia design system
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m47s
2026-09-30 18:21:09 -05:00
ekklesia 3ca6b2cb32 feat: expose canonical module display URLs 2026-09-30 18:20:40 -05:00
The_River 75324af045 Merge pull request 'fix: stabilize dashboard content rows' (#42) from fix/signage-runtime-layout into main
CI / verify (push) Successful in 1m26s
CI / infrastructure-smoke (push) Successful in 23s
CI / migration-smoke (push) Successful in 1m52s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/42
2026-09-30 02:04:13 +00:00
ekklesia f445a83ea5 fix: stabilize dashboard content rows
CI / verify (pull_request) Successful in 1m30s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 1m52s
2026-09-29 20:50:14 -05:00
The_River a130eb78ea Merge pull request 'fix: keep dashboard shell dimensions stable' (#41) from fix/signage-dashboard-layout into main
CI / verify (push) Successful in 53s
CI / infrastructure-smoke (push) Successful in 23s
CI / migration-smoke (push) Successful in 1m56s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/41
2026-09-29 23:55:56 +00:00
ekklesia 8127cc87b4 style: format signage dashboard shell changes
CI / verify (pull_request) Successful in 55s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 2m6s
2026-09-29 18:52:07 -05:00
ekklesia 2969ed4992 fix: keep dashboard shell dimensions stable
CI / verify (pull_request) Failing after 16s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
2026-09-29 18:15:46 -05:00
The_River 14ac46bd3d Merge pull request 'Phase 8/module platform prerequisites' (#40) from phase-8/module-platform-prerequisites into main
CI / verify (push) Successful in 46s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m45s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/40
2026-09-06 00:41:22 +00:00
ekklesiaandClaude Opus 5 bb7a9eb351 fix: let the display worker ignore requests it does not handle
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m44s
The fetch listener called `respondWith` for every request and re-issued the
unrecognized ones itself. That routes the whole page through the worker,
re-sends request bodies from a different context, and makes the worker's
presence observable on requests it has no interest in.

Classify synchronously and respond only for the shell and artifact URLs this
worker actually owns. Not responding is what leaves the browser doing exactly
what it would have done without a worker at all — which is the property the
allowlist was written to preserve.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:17:04 -05:00
ekklesiaandClaude Opus 5 20d5128bad docs: record the five Phase 8 prerequisites, and report contributions
Bring the living documentation in line with what the five passes actually
shipped, and correct the claims they superseded rather than leaving them to
mislead: the module UI context is six members, not five; module styling is
compiled CSS, not inline objects only; the strict manifest is version 7.

Also close a silent failure the new contribution mechanism introduces. A
contribution whose target capability nobody provides — or whose provider moved
outside its declared range — is not published, and the manifest is valid and
the artifact exists, so an operator sees a screen that renders nothing with no
error anywhere. `pnpm modules:validate` now lists each declared contribution
with its capability and range and says whether a compatible provider is
installed, and Doctor's discovery check reports the same per module. Both
derive from the publication registry the API serves from, so neither can
disagree with what a running platform would actually publish.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:16:01 -05:00
ekklesiaandClaude Opus 5 ca3f37d5f9 feat: let a display survive its network — as code, not as authorization
A display is the one surface expected to outlive its network: a television on
a wall in a building whose uplink is a consumer router, with nobody standing
in front of it. When that uplink drops and the screen reboots, the browser
shows its own error page — not the display shell, not an Ekklesia message,
nothing that recovers on its own, because there is no Ekklesia code left
running to recover.

Cache the shell and the declared code, in a service worker scoped to
`/display/`: the page, the host script, the module's display artifact, and
every contributed renderer that display actually mounted. All of them are code
whose bytes already confer no authority, and all of them are addressed by a
URL carrying an exact version.

Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a
module or platform API response, not the realtime stream, not a pairing
exchange, and not any cross-origin request — which is what a signed
object-delivery URL is. It is an allowlist rather than a deny list, so the
cost of forgetting a new endpoint is that code goes uncached rather than that
a permission decision gets stored.

The consequence is stated rather than papered over: a display cannot render
module content while offline. Mounting needs a descriptor, a descriptor is
authorization-bearing, and it is never cached — so a revoked device stays
revoked and cached code can never manufacture current authorization. What the
cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own
offline state and keeps retrying, that reconnecting mounts with nothing left
to download, and that a hiccup fetching 600 KB of artifact after the tiny
descriptor already resolved falls back to identical bytes instead of failing
the display.

Network-first everywhere, so a screen picks up new code the moment the server
returns and can never be trapped on an obsolete build — the failure mode a
cache-first worker produces on a screen nobody visits. Only a complete
same-origin 200 is stored. Cache names carry a policy version and superseded
generations are deleted; artifacts are pruned to the current version per
module, keeping every artifact belonging to that version because a display
artifact and its contributed renderers share one. The worker never caches
itself.

The page gets a small signal built from two sources, because either alone
lies: `navigator.onLine` is optimistic behind a captive portal, and only the
worker knows it actually served from cache. A display shows a quiet corner
indicator rather than blanking a lobby wall, and resolves its descriptor again
by itself when the network returns — nobody presses Retry on a lobby TV.

Every caching decision lives in a module that touches no global, so the
behaviour is exercised directly rather than through a worker a test runner
cannot host. Registration is an enhancement: no service workers, plain HTTP,
or a refusing policy all leave a display that works exactly as before.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 16:08:34 -05:00
ekklesiaandClaude Opus 5 e36485bf99 feat: let a module route stream an image-sized upload, explicitly and bounded
ADR 0017 capped a module route at 1 MiB and said large media belonged to a
future storage design. That design arrived — ADR 0030 gave modules a bucket,
ADR 0031 took Ekklesia out of the read path — and it did not solve the write
path: signed write URLs were deliberately declined, so the only way bytes
reach a bucket is a module's own route, and a photograph is not 1 MiB.

Extend the route contract rather than adding a second HTTP system. A route
declares `bodyMode`: `buffered` (the default, and what every existing route
already meant) keeps the 1 MiB ceiling and materializes the body before the
handler runs; `streamed` raises the ceiling 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 mean an author who raised a
limit silently changed how their own handler receives its body and when a 413
can reach their own code.

32 MiB holds one 25 MiB image plus bounded overhead and is also the platform's
absolute ceiling: no route in any mode may declare more, and no unbounded
option exists in the contract. The unit is one file per request — twenty
images are twenty individually bounded requests, and nothing here can express
a 500 MiB aggregate whose failure would waste everything already sent.

Replace the buffering dependency with the platform's own limiter. A declared
Content-Length over the limit is refused synchronously, before authorization
and before a byte is read. Buffered mode now counts what actually arrives
rather than trusting the header, which closes a gap the previous
implementation had. Streamed mode wraps the body in a counting transform;
on overflow the stream errors, the source is cancelled, and the response is
replaced with the same sanitized 413 even if the handler swallowed the error —
a limit a handler can swallow is not a limit. An unread body is cancelled so
an abandoned upload releases its socket, and an abort stays an ordinary failed
request rather than becoming a 413.

Ordering is unchanged and now strictly better: in streamed mode no byte is
read until the handler reads it, and the handler runs only after authorization
succeeds, so an unauthorized caller cannot push an upload through at all.

The live suite uploads a real 25 MiB body over a real socket end to end.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 15:57:44 -05:00
ekklesiaandClaude Opus 5 c444ff570b feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.

Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.

Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.

A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.

The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.

The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.

The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 07:47:10 -05:00
ekklesiaandClaude Opus 5 07835b7b56 feat: give module UI real stylesheets, and every artifact its own root
ADR 0023 refused stylesheet imports for a good 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 story. They cannot express
`:hover`, `:focus-visible`, `:disabled`, `@media`, or `@keyframes` — so an
accessible focus treatment was not expressible, and the shared elements added
in the previous commit are built from real CSS for exactly that reason.

Compile the stylesheet into the artifact instead of beside it. A module
imports plain `.css`; the canonical builder resolves local `@import`s through
a nested bundle and turns each file into its compiled text, which the author
declares as `defineModuleUi({ styles })`. One declared file still contains
everything: nothing extra to publish, nothing to fetch, and no way for CSS to
become an asset nobody serves. The build additionally fails if a CSS output
ever appears beside an artifact, so a future loader change cannot bring the
sidecar back. Two rules are enforced with their own codes: a module stylesheet
may reference `--ekklesia-*` and never declare one, and every `@import` must
resolve locally or the artifact would fetch styles at run time. Plain `.css`
only; other dialects are a toolchain decision needing their own ADR.

Then make the isolation real. The generic loader mounts every browser artifact
— primary, display, and later a contributed renderer — inside an open Shadow
DOM root it creates, installs the artifact's stylesheet there, and hands the
artifact an element inside it. Shadow DOM is the one mechanism a browser
actually enforces; a class prefix or a "scope your selectors" instruction is a
convention one mistake defeats, and the failure mode is a module silently
restyling the Dashboard. It applies unconditionally, including to artifacts
with no stylesheet, because a conditional boundary is one a module can fall
out of by editing a source file. Both wrappers are `display: contents` through
base rules installed before the artifact's own, so layout is unchanged and a
full-viewport display can still override `:host`. Unmount removes the whole
root in a `finally`, so a cleanup that throws cannot leave styles behind, and
remounting cannot accumulate a second copy.

This is a correctness boundary for styles and is not described as a sandbox:
the root is `open` precisely because module code is trusted, same-realm code
that could reach `document` anyway.

An artifact's DOM is no longer a direct child of the host's element, so the
mount ABI is versioned explicitly. `defineModuleUi` stamps
`MODULE_UI_ABI_VERSION` so it can never drift from the SDK the artifact
compiled against, and a version-1 artifact fails with its own code — "rebuild
your module", not a typo hunt. No dual acceptance, matching every manifest
cutover here. Migration is one `pnpm build`.

`pnpm boundaries` allows `.css` only under `src/ui/` and `src/display/`: a
stylesheet anywhere else in a module is the undeclared sidecar again, just
relocated.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 07:21:14 -05:00
ekklesiaandClaude Opus 5 e03d38fcab feat: give every Ekklesia screen one set of design tokens and elements
Modules have owned their own UI since ADR 0021, but the platform has never
said what that UI should look like. Every screen here — the Dashboard shell,
the display host, the module template — invented its own hex colours, font
stack, spacing, and radii, and they already disagreed. A module author had no
vocabulary for "the ordinary panel background", so the only available answer
was to invent one, and two modules would invent two.

Add `@ekklesia/design-tokens`: one validated, enumerable registry of semantic
tokens. A token declares a name, label, description, and default value; its
category and `--ekklesia-*` identifier are derived from the name, so it can
never claim one category while being named for another. Construction rejects
duplicates, malformed names, unknown categories, empty or overlong text, and
any value that is not a bounded literal — no declaration terminator, rule
block, comment, at-rule, `url(`, `!important`, escape, control character, or
markup. A registry that constructs can therefore be emitted as CSS without
escaping, because nothing needing escaping can be in it. That validation is
also what makes a future theme editor implementable rather than aspirational.

Add `@ekklesia/ui`: button, icon button, text input, select, checkbox, field
wrapper, 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 depends on colour alone.
Deliberately small: an element earns its place when two consumers need it.

The stylesheet is a string rather than a `.css` file so the identical bytes
install into a document or an isolated artifact root, and so hover, disabled,
and focus-visible are real CSS rather than JavaScript approximations of them.

`pnpm boundaries` opens exactly two shared roles to modules, by name — every
other role stays rejected — and pins both design packages to the bottom of the
presentation stack. Both are browser-safe by construction, proven by a real
compile of module-shaped source in which `process.env` must fail to typecheck.

The shell installs the defaults at its document root for the Dashboard and the
display; custom properties inherit into module artifacts, so a module reads
`var(--ekklesia-…)` with nothing to import at run time and nothing for a host
to forget to pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 06:59:44 -05:00
The_River b14613decd Merge pull request 'fix: let a deployment name itself in the SMTP greeting' (#39) from phase-7c/smtp-helo-name into main
CI / verify (push) Successful in 41s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m41s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/39
2026-08-26 01:42:03 +00:00
ekklesiaandClaude Opus 5 3563549e86 fix: let a deployment name itself in the SMTP greeting
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m41s
A real deployment could not send mail through an IP-authorized relay.
Every attempt surfaced as MAIL_PROVIDER_UNAVAILABLE while the relay was
healthy and swaks sent from the same machine.

The cause was the greeting, and it is generic rather than tied to any
provider. 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, which is the normal state of a container. The relay
closed the connection on it with a 421 before any message was offered,
which is indistinguishable from the relay being down.

EKKLESIA_SMTP_HELO_NAME now lets the deployment say what to greet as. It
is validated as a fully qualified domain name — no scheme, port,
brackets, whitespace, or control characters, and at least one dot, since
a single unqualified label is exactly the input Nodemailer already
discards. It is not a credential: it travels in the clear on every
connection, so Doctor prints it where an operator can compare it against
what the relay expects, and Doctor's probe performs the same greeting the
runtime does.

Absent preserves Nodemailer's behaviour exactly. It is deliberately not
defaulted to the sender's domain: a machine whose hostname is already a
valid FQDN may be greeting correctly, and some relays check that against
reverse DNS, so replacing it silently would trade one deployment's bug
for another's.

The regression guard is a raw SMTP server that refuses any greeting but
the expected one, because smtp-server exposes no EHLO hook and its
earliest hook is never reached by a send-nothing probe.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:28:30 -05:00
The_River 450d65116f Merge pull request 'feat: verify email addresses and recover forgotten passwords' (#38) from phase-7c/email-verification-recovery into main
CI / verify (push) Successful in 41s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 1m42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/38
2026-08-25 23:45:25 +00:00
ekklesiaandClaude Opus 5 ea28f8aa2e feat: verify email addresses and recover forgotten passwords
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
Ekklesia could authenticate a person and, since Pass 1, send a
transactional email — but it had no way to establish that an address
belonged to whoever claimed it, and no way back in for someone who
forgot their password.

A verified address is now a precondition for holding a session. Better
Auth enforces that after the password check and before any session
exists, so an unverified sign-in returns 403 with no cookie. That is why
no "unverified principal" was needed: a correct password on an
unverified address carries exactly the authority of no password, and
/api/v1/principal is unchanged.

Better Auth keeps every token. Ekklesia adds no token table, no hashing,
and no expiry clock. It supplies a recipient, a subject, and two bodies,
and the mail provider supplies everything about SMTP.

Verification and reset links are rebuilt from a validated Dashboard
origin, taking only the token — never the URL Better Auth prebuilds from
a caller-supplied callbackURL — so no request can steer where a
recipient lands. Following a verification link mints no session, because
mail infrastructure dereferences links. Reset revokes that person's
other sessions and leaves managed devices alone.

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: created already
verified by an operator command with no mail prerequisite, so a fresh
server can be signed in to before a relay exists. Migration 0007 extends
that to owners of installations that already exist. Public signup stays
disabled; verification is not a licence to self-register.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 07:21:17 -05:00
The_River 3581ad7c8e Merge pull request 'fix: give Doctor the host configuration the runtime receives' (#37) from phase-7c/doctor-config-parity into main
CI / verify (push) Successful in 41s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 1m24s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/37
2026-08-25 00:20:51 +00:00
ekklesiaandClaude Opus 5 2b4f0afb72 fix: give Doctor the host configuration the runtime receives
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m24s
`pnpm doctor` and `startApi` each read their own subset of the same
environment variables, and the subsets had drifted. Doctor never read
`S3_*` or module secrets at all, so a correctly configured installation
was told its object storage was "not configured" and every module
requiring a bucket or a secret was reported blocked -- while the runtime,
from the identical environment, bound those buckets and ran the modules.

Both executables now map the environment through one
`loadHostConfiguration(env)` in Core, which takes the environment as an
argument and never reads `process.env` itself. Validation stays with the
existing per-domain validators, so each caller keeps its own failure
behaviour: startup refuses a rejected value, Doctor reports it as a
sanitized code.

Doctor's storage option becomes raw input like its mail option, so a
half-configured provider is reported as a blocking configuration failure
instead of throwing before a report exists. The outbound-email check --
computed, counted toward the result, but absent from every rendered
section -- is now printed, and the rendered set is asserted against a
real report so a future check cannot go missing the same way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 19:15:49 -05:00
The_River a835215eb8 Merge pull request 'feat: add a host-provided outbound email transport' (#36) from phase-7c/outbound-email into main
CI / verify (push) Successful in 40s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m24s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/36
2026-08-24 23:46:22 +00:00
ekklesiaandClaude Opus 5 fa3021e49b feat: add a host-provided outbound email transport
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s
Ekklesia could authenticate people and pair devices but could not send a
single message. Better Auth has been configured with emailAndPassword
since ADR 0011 and neither of its mail hooks — sendResetPassword and
emailVerification.sendVerificationEmail — was ever implemented, so email
verification, password recovery, and invitations were all blocked on the
same missing thing.

That missing thing is a transport, not a Google Workspace integration.
Email is now a host-provided infrastructure service with a provider seam,
shaped exactly like object storage: feature code says "send this
transactional message" and the provider owns the host, port, TLS,
credentials, connection behaviour, and every transport error. No
provider's hostname appears anywhere in Core, and a test asserts it.
Google Workspace SMTP Relay is the expected first deployment and a
compatibility target — the same variables address Microsoft 365, a
transactional provider, or a local Postfix.

The contract is deliberately tiny: one recipient or a small bounded list,
a subject, text and/or HTML, an optional reply-to, and nothing else. There
is no `from` field at all — the sender is host configuration, so no
feature and no future module can choose who mail appears to come from,
which is how a platform otherwise acquires an impersonation primitive.
Control characters are refused rather than stripped in addresses, display
names, subjects, and reply-to values; writing that rule down was not
enough, because the first implementation trimmed an address before
checking it and silently accepted a trailing carriage return until a test
caught it.

Security modes are named rather than inferred from a boolean and a port,
which is genuinely ambiguous: `secure: false` on 587 means "connect in the
clear and hope STARTTLS is offered". `starttls` sets requireTLS so a
stripped upgrade fails the send instead of continuing in plaintext,
production refuses `plaintext` outright, and certificate verification has
no off switch. Credentials are optional as a pair, because a relay
authorized by source IP has neither and requiring them would make the
intended production arrangement unconfigurable; half a pair fails loudly.

Connection, greeting, and socket timeouts are all explicit — the
object-storage pass already showed what a network client on library
defaults does to a request. Nothing retries: a failure after the message
data has gone out is ambiguous, and retrying it turns one password-reset
request into several emails, so this is a single bounded attempt and
deliberately not an exactly-once promise.

Email stays optional. An installation with no SMTP host is a complete
deployment, nothing sends at startup, and constructing the transport opens
no socket. There is no POST /api/v1/send-email and no route that touches
the provider — an authenticated send endpoint is an open relay with extra
steps. Modules get nothing either; services are added one at a time when
something real needs one, and nothing does yet.

Proven against a real in-process SMTP server rather than a mock: delivery
with the host's sender on the wire, authentication and its sanitized
refusal, a rejected mailbox distinguished from an outage, a refused
connection, a silent server hitting its deadline, and a starttls 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.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 18:37:16 -05:00
238 changed files with 22221 additions and 944 deletions
+64
View File
@@ -1,4 +1,8 @@
# Local development only. Replace every credential outside a developer workstation. # Local development only. Replace every credential outside a developer workstation.
#
# `pnpm doctor` loads this file's uncommitted `.env` counterpart and evaluates exactly the host
# infrastructure configuration `pnpm api:start` would use, so the readiness report reflects the real
# deployment without the API process running.
# Required for a local workstation, and only ever correct there. Anything else — including an # Required for a local workstation, and only ever correct there. Anything else — including an
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS # unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
@@ -44,3 +48,63 @@ S3_BUCKET_PREFIX=ekklesia
# Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider — # Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider —
# physical buckets are derived from a module's manifest declaration, not configured here. # physical buckets are derived from a module's manifest declaration, not configured here.
S3_BUCKET=ekklesia-development S3_BUCKET=ekklesia-development
# Ekklesia's outbound email transport (ADR 0037). Absent EKKLESIA_SMTP_HOST means this installation
# sends no email, which is a complete deployment: nothing in the platform sends any yet, and a
# future feature that needs to is told so rather than crashing.
#
# These are host infrastructure. No module ever sees them, and no feature chooses its own sender:
# the From address below is the only one this platform can send as.
#
# EKKLESIA_SMTP_HOST is a bare hostname — never a URL, a port, or a connection string.
# EKKLESIA_SMTP_SECURITY is exactly one of:
# implicit-tls TLS from the first byte, conventionally port 465
# starttls plaintext connect then a *required* upgrade, conventionally 587 (the default)
# plaintext no TLS at all; development only, and production refuses it outright
# EKKLESIA_SMTP_PORT is optional and defaults to the conventional port for the mode.
#
# Credentials are optional **as a pair**, because a relay authorized by source IP has neither.
# Setting one without the other is refused rather than silently connecting anonymously. Never commit
# a real password: this file is in every clone.
#
# A Google Workspace SMTP Relay deployment typically looks like:
# EKKLESIA_SMTP_HOST=<the relay host from Google Workspace Admin>
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# [email protected]
# Whether it also needs a username and password depends on the relay's authorization setting in
# Google Workspace Admin, which varies per deployment. See docs/development/email.md.
# EKKLESIA_SMTP_HOST=
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# EKKLESIA_SMTP_USERNAME=
# EKKLESIA_SMTP_PASSWORD=
# [email protected]
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
#
# EKKLESIA_SMTP_HELO_NAME is the name Ekklesia announces itself as in the SMTP EHLO command.
#
# Leave it unset unless a relay refuses the connection. An SMTP client has to identify itself, and
# with nothing configured Nodemailer derives that from the machine hostname — substituting the
# address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal
# state of a container or an unnamed VM. Some relays close the connection on that greeting with a
# 421 before any message is offered, which looks exactly like the relay being unreachable.
#
# Set it to a fully qualified domain name your deployment owns — typically the sending domain.
# No scheme, no port, no brackets, and at least one dot; anything else is refused at startup.
# EKKLESIA_SMTP_HELO_NAME=example.org
# Where an emailed verification or password-reset link sends a browser (ADR 0038).
#
# Absent means the Dashboard is served from EKKLESIA_IDENTITY_BASE_URL, which is the ordinary
# same-origin production shape. Set it only when a deployment separates the two, for example an API
# at https://api.example.org and the Dashboard at https://dash.example.org. It must also appear in
# EKKLESIA_IDENTITY_TRUSTED_ORIGINS, or the identity system refuses the emailed link — Ekklesia
# checks that at startup rather than letting every recipient discover it.
#
# This is the authenticated Dashboard host, never the public church website.
# EKKLESIA_DASHBOARD_ORIGIN=https://dash.example.org
#
# Verification and password recovery need a working relay above. The initial platform-owner
# bootstrap (pnpm identity:create-initial-owner) deliberately does not: it creates that one account
# already verified, so a fresh server can be signed in to before any mail exists.
+1 -1
View File
@@ -15,7 +15,7 @@ monorepo:
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product - **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
packages. They own their own data and migrations, declare capabilities/permissions through packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
surface directly. Per ADR 0021 a module 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 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 `@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. `pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
+22 -7
View File
@@ -35,13 +35,21 @@ pnpm infrastructure:config
private source paths. private source paths.
- Use `workspace:` ranges for internal package dependencies. - Use `workspace:` ranges for internal package dependencies.
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may - Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and 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 `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 the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
bundler configuration, and styling is React inline style objects only for now. Both roles use the browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
same UI SDK context with no presentation-mode flag. 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 - 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 `@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
@@ -51,10 +59,17 @@ pnpm infrastructure:config
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
through `GET /api/v1/module-displays/<module-id>`, mounts both with through `GET /api/v1/module-displays/<module-id>`, mounts both with
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033). `@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 - 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 `/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 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 - 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 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 the dependency contract's job alone, and grouping must never influence discovery, dependency
+147 -7
View File
@@ -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 production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and [API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
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 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 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, 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 externalizing it would make every host supply a compatible React — defeating standalone hosting. The
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template); accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
sharing one runtime later is a build optimization that does not change the contract. Styling is React sharing one runtime later is a build optimization that does not change the contract. Styling is React
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds, `.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
and installing a module or starting the API never triggers one. and installing a module or starting the API never triggers one.
@@ -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 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 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 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 **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 no key management, so a `secret` key's value is supplied by the deployment
@@ -691,6 +694,60 @@ targeting, device groups, and mobile dashboards are deliberately deferred** —
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
identity at all. identity at all.
Phase 7C pass 1 adds the platform's first **outbound email transport**
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
authenticate people and pair devices but could not send a single message, which is what blocked
email verification, password recovery, and invitations alike. Email is now a host-provided
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
this transactional message* and the provider owns the host, port, TLS, credentials, and every
transport error. **No provider's hostname appears anywhere in Core**, and a test asserts it —
Google Workspace SMTP Relay is the expected first deployment, not a platform dependency.
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
mail appears to come from. Control characters in addresses, subjects, and display names are refused
rather than stripped, which is what closes header injection.
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
relay authorized by source IP has neither — and the password is a deployment secret that never
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
single bounded attempt and deliberately not an exactly-once promise. There is **no
`POST /api/v1/send-email`** and no route that touches the provider.
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
while stating plainly that a successful connection does not establish deliverability. See
[Outbound email](docs/development/email.md).
Phase 7C pass 2 completes the human identity lifecycle over that transport
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
verified email address is a precondition for holding a session**: the identity system refuses an
unverified sign-in after checking the password and before creating one, so a correct password on an
unverified address carries exactly the authority of no password at all — which is why no
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
can steer where a recipient lands, and following a verification link deliberately mints **no
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
leaves managed devices untouched. Password recovery answers identically for an address with an
account, without one, already verified, or undeliverable — including when SMTP is broken, which is
why the delivery adapter never throws.
**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
can be signed in to before any relay exists. Normal production use of verification and recovery does
require configured outbound email. **Public signup remains disabled** — verification is not a licence
to self-register — and invitations and user administration are still not built. See
[Identity](docs/development/identity.md).
Ekklesia publishes the display bundle at Ekklesia publishes the display bundle at
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at `GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
`/display/<module-id>`, and exposes that page's permission-gated descriptor at `/display/<module-id>`, and exposes that page's permission-gated descriptor at
@@ -737,6 +794,74 @@ unproven launch-blockers rather than existing guarantees. Read
[Security model and deployment trust boundaries](docs/development/security.md) before installing a [Security model and deployment trust boundaries](docs/development/security.md) before installing a
module or exposing the API. module or exposing the API.
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
None of them is a feature module, and none of them names one.
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
badge, toast, and empty/error/loading states), every value a token reference, with real
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
exactly two shared roles to modules by name and pins both design packages beneath every other role.
The Dashboard and display install the defaults at their document root; custom properties inherit into
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
theme editor, brand builder, second theme, or component catalogue exists.**
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared
range; a contribution that fails is absent rather than broken. A consumer discovers through
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
database, storage, or credential — and loading one grants its module nothing.
Phase 8 pass 4 closes the object-storage write path
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
oversize is refused before authorization, the buffered path now counts what arrived rather than
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
every contributed renderer that display mounted, network-first so a screen can never be trapped on
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
stated rather than papered over — a display cannot render module content while offline, because
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
## Quick start ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -756,8 +881,18 @@ pnpm verify
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed `pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration (TLS) policy, optional read-only PostgreSQL planning, and the current object-storage and
state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset outbound-email integration state.
Doctor reads its host infrastructure configuration through the same
`loadHostConfiguration(process.env)` the API executable uses, so both derive identical settings and
identical defaults from one deployment environment: configured object storage is reported as
configured, a module secret the deployment has set is reported as satisfied, and a value neither
process could accept is reported as a configuration failure rather than silently interpreted one way
by the diagnostic and another way at startup. An optional service that is genuinely absent — no
`S3_ENDPOINT`, no `EKKLESIA_SMTP_HOST` — is still a complete deployment and reported as skipped.
`.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
development infrastructure provides is only usable with it set. development infrastructure provides is only usable with it set.
@@ -782,7 +917,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
5 permissions registered 6 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -792,13 +927,14 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
6 migrations · checksums valid 7 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180 core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4 core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5
PostgreSQL PostgreSQL
✓ Database TLS verified ✓ Database TLS verified
@@ -821,6 +957,10 @@ Object storage
✓ No module requires object storage ✓ No module requires object storage
0 modules declare a logical storage bucket. 0 modules declare a logical storage bucket.
Outbound email
○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS Result: READY WITH WARNINGS
``` ```
+4
View File
@@ -28,11 +28,15 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"better-auth": "1.6.23", "better-auth": "1.6.23",
"happy-dom": "20.0.11",
"pg": "8.22.0", "pg": "8.22.0",
"smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.10" "vitest": "4.1.10"
}, },
+187 -17
View File
@@ -1,6 +1,10 @@
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts"; import {
import type { Context } from "hono"; MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
import { bodyLimit } from "hono/body-limit"; MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
type ModuleRouteBodyMode,
} from "@ekklesia/contracts";
import type { Context, MiddlewareHandler } from "hono";
/** /**
* The platform's one request-body policy, centralized here rather than scattered as magic numbers * The platform's one request-body policy, centralized here rather than scattered as magic numbers
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting * field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
* abuse. * abuse.
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same * - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
* constant a module's own route declaration is validated against at registration) is the hard * constant a module's own **buffered** route declaration is validated against at registration) is
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling: * the ceiling for every body the platform materializes before a handler runs. It has not moved.
* nothing in this platform ever declares, or enforces, a larger single request body than this. * - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to * explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
* a future dedicated storage design, not to widening this ceiling. * `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
* single request body, and no unbounded option exists anywhere in the contract.
*/ */
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024; export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024; export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING }; export {
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
};
/** The one stable, sanitized public contract for every request-body-size rejection across the /** The one stable, sanitized public contract for every request-body-size rejection across the
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending * platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413); return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
} }
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413 /**
* contract rather than redefining `onError` per route. Covers both a declared oversized * A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
* `Content-Length` and an oversized chunked/streamed body with no declared length — see *
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before * A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as * the byte counting below bounds the request either way, so refusing here could only ever turn a
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */ * legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
export function platformBodyLimit(maxSize: number) { * parser rejects genuinely malformed framing before this code sees the request at all.
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse }); */
function declaredContentLength(request: Request): number | undefined {
const header = request.headers.get("content-length");
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
return undefined;
}
return Number.parseInt(header.trim(), 10);
}
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
function releaseUnreadBody(request: Request): void {
const body = request.body;
if (!body || body.locked) {
return;
}
void body.cancel().catch(() => undefined);
}
/**
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
* chunk past the limit rather than the whole upload.
*/
async function readBounded(
body: ReadableStream<Uint8Array>,
maxSize: number,
): Promise<readonly Uint8Array[] | "over-limit"> {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
return chunks;
}
size += value.byteLength;
if (size > maxSize) {
await reader.cancel().catch(() => undefined);
return "over-limit";
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
}
/**
* The platform's one request-body limiter, for every route in the application.
*
* ## Both modes reject a declared oversize before anything else happens
*
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
* too-large upload takes.
*
* ## `buffered` — the body is materialized, bounded, before the handler runs
*
* The exact behaviour every route had before streaming existed, now with the counting done here
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
* what actually arrived rather than by what was claimed. The handler then receives a request whose
* body is fully available and provably within its declared allowance — which is what makes
* `await request.json()` safe in a module handler.
*
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
*
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
*
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
* is not a limit.
*
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
* handler has started, so a handler that persists as it reads has to be prepared for a partially
* consumed upload. Writing to a temporary key and committing only after the read completes is the
* pattern; the platform cannot do it for you, because only the module knows what a partial write
* means to its own data.
*
* ## Ordering
*
* This runs before permission evaluation for both modes, so a request that is *declared* too large
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
* push a large upload through the platform at all.
*
* ## Timeouts
*
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
* platform already has one answer for that rather than a second per-route one.
*/
export function platformBodyLimit(
maxSize: number,
mode: ModuleRouteBodyMode = "buffered",
): MiddlewareHandler {
return async function enforceBodyLimit(c, next) {
const request = c.req.raw;
const declared = declaredContentLength(request);
if (declared !== undefined && declared > maxSize) {
return requestBodyTooLargeResponse(c);
}
if (!request.body) {
return next();
}
if (mode === "buffered") {
const chunks = await readBounded(request.body, maxSize);
if (chunks === "over-limit") {
return requestBodyTooLargeResponse(c);
}
c.req.raw = new Request(request, {
body: streamOf(chunks),
// Required by undici whenever a `Request` is constructed from a stream.
duplex: "half",
} as RequestInit);
return next();
}
let overLimit = false;
let total = 0;
const counted = request.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
total += chunk.byteLength;
if (total > maxSize) {
overLimit = true;
// Erroring the transform propagates back to the source and cancels it, so the socket is
// released rather than left draining an upload nobody will read.
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
return;
}
controller.enqueue(chunk);
},
}),
);
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
await next();
if (overLimit) {
c.res = requestBodyTooLargeResponse(c);
}
releaseUnreadBody(c.req.raw);
return;
};
} }
+28 -2
View File
@@ -1,8 +1,8 @@
import { import {
assertProductionDatabaseTransport, assertProductionDatabaseTransport,
createDatabaseTransportPolicy,
type DatabaseTransportPolicy, type DatabaseTransportPolicy,
type DatabaseTransportPolicyInput, type DatabaseTransportPolicyInput,
createDatabaseTransportPolicy,
} from "@ekklesia/core/database"; } from "@ekklesia/core/database";
import { import {
type CreateIdentityConfigOptions, type CreateIdentityConfigOptions,
@@ -10,6 +10,11 @@ import {
type IdentityConfig, type IdentityConfig,
type IdentityConfigInput, type IdentityConfigInput,
} from "@ekklesia/core/identity"; } from "@ekklesia/core/identity";
import {
createMailProviderConfig,
type MailProviderConfig,
type MailProviderConfigInput,
} from "@ekklesia/core/mail";
import { import {
createStorageProviderConfig, createStorageProviderConfig,
type StorageProviderConfig, type StorageProviderConfig,
@@ -70,12 +75,24 @@ export interface ApiRuntimeConfig {
* bundle, diagnostic, or API response ever sees the endpoint or the credentials. * bundle, diagnostic, or API response ever sees the endpoint or the credentials.
*/ */
readonly storage: StorageProviderConfig | undefined; readonly storage: StorageProviderConfig | undefined;
/**
* The validated host outbound-email provider, or `undefined` when this installation has none
* (ADR 0037).
*
* `undefined` is a valid deployment: the platform starts, every existing feature works, and the
* initial platform owner — created already verified by an operator command — can still sign in
* (ADR 0038). What it cannot do is verify a new account or deliver a password reset; each attempt
* reports a sanitized `MAIL_NOT_CONFIGURED` out of band rather than changing any HTTP response.
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
* API response ever sees the host or the password.
*/
readonly mail: MailProviderConfig | undefined;
} }
export interface ApiRuntimeConfigInput { export interface ApiRuntimeConfigInput {
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy /** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
* trust unless explicitly configured. See `./client-address.js`. */ * trust unless explicitly configured. See `./client-address.js`. */
readonly clientAddress?: ClientAddressConfigInput; readonly clientAddress?: ClientAddressConfigInput | undefined;
readonly databaseUrl?: unknown; readonly databaseUrl?: unknown;
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development; /** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */ * production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
@@ -85,6 +102,8 @@ export interface ApiRuntimeConfigInput {
readonly port?: unknown; readonly port?: unknown;
/** Optional. Omitted, or with no endpoint, means this installation has no object storage. */ /** Optional. Omitted, or with no endpoint, means this installation has no object storage. */
readonly storage?: StorageProviderConfigInput; readonly storage?: StorageProviderConfigInput;
/** Optional. Omitted, or with no host, means this installation sends no outbound email. */
readonly mail?: MailProviderConfigInput;
} }
export interface CreateApiRuntimeConfigOptions { export interface CreateApiRuntimeConfigOptions {
@@ -164,12 +183,19 @@ export function createApiRuntimeConfig(
// fails startup with a sanitized code rather than surfacing later as a confusing storage error. // fails startup with a sanitized code rather than surfacing later as a confusing storage error.
const storage = createStorageProviderConfig(input.storage, { environment }); const storage = createStorageProviderConfig(input.storage, { environment });
// Validated in the same pass as everything else, so a malformed port, a half-supplied credential
// pair, or a production deployment that asked for plaintext SMTP fails startup with a sanitized
// code — rather than surfacing later as a confusing failure the first time something tries to
// send a password-reset email.
const mail = createMailProviderConfig(input.mail, { environment });
return Object.freeze({ return Object.freeze({
clientAddress, clientAddress,
databaseTransport, databaseTransport,
databaseUrl, databaseUrl,
host, host,
identity, identity,
mail,
port, port,
storage, storage,
}); });
+16 -5
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets"; 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 * 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 * 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 html: string;
readonly script: string; readonly script: string;
readonly scriptEtag: 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 /** 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 * 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. * failed host build rather than a valid page that can never mount anything.
*/ */
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets { export function createDisplayHostAssets(
if (html.trim().length === 0 || script.trim().length === 0) { html: string,
script: string,
serviceWorker: string,
): DisplayHostAssets {
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
throw new DisplayHostAssetError(); throw new DisplayHostAssetError();
} }
return Object.freeze({ return Object.freeze({
html, html,
script, script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, 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. */ * than leaving a display route that can authorize successfully and then return a broken shell. */
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> { export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
try { try {
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
// rejected sibling promise is left running after this function has already failed. // rejected sibling promise is left running after this function has already failed.
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8"); const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
const script = await readFile(new URL(displayHostAssetUrls.script), "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 { } catch {
throw new DisplayHostAssetError(); throw new DisplayHostAssetError();
} }
+16
View File
@@ -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)}`;
}
+64 -13
View File
@@ -7,24 +7,37 @@ import {
requirePermission, requirePermission,
} from "./authorization-middleware.js"; } from "./authorization-middleware.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.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 { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-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"; import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under /** 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. */ * `/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 const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
/**
export function moduleDisplayPageUrl(moduleId: string): string { * Where the display's offline service worker is served (ADR 0043).
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`; *
} * 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
export function moduleDisplayDescriptorUrl(moduleId: string): string { * `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`; * 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 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 * 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 id: string;
readonly version: string; readonly version: string;
readonly display: { readonly url: 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 api: { readonly baseUrl: string };
readonly realtime: { readonly baseUrl: string }; readonly realtime: { readonly baseUrl: string };
readonly permissions: readonly 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. */ * bytes with a stale validator. */
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets { function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
try { try {
const snapshot = createDisplayHostAssets(input.html, input.script); const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
if (snapshot.scriptEtag !== input.scriptEtag) { if (
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
) {
throw new ModuleDisplayConfigurationError(); throw new ModuleDisplayConfigurationError();
} }
return snapshot; return snapshot;
@@ -132,8 +154,10 @@ async function describeDisplay(
viewer, viewer,
module: { module: {
api: { baseUrl: moduleApiBaseUrl(display.moduleId) }, api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
display: { url: moduleDisplayUiArtifactUrl(display) }, display: { url: moduleDisplayUiArtifactUrl(display) },
id: display.moduleId, id: display.moduleId,
page: { url: moduleDisplayPageUrl(display.moduleId) },
permissions: await grantedModulePermissionIds( permissions: await grantedModulePermissionIds(
options.evaluator, options.evaluator,
principal, 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) { for (const display of displays) {
mountDisplay(app, { ...options, assets }, display); mountDisplay(app, { ...options, assets }, display);
} }
+6 -3
View File
@@ -235,9 +235,12 @@ export function mountModuleHttpRoutes(
const requireRoutePermission = requirePermission(middlewareOptions, route.permission); const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at // The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
// all) is enforced before permission evaluation ever runs, so an oversized request never // all) is enforced before permission evaluation ever runs, so a request *declared* oversized
// reaches authorization work, let alone the handler or the module database. // never reaches authorization work, let alone the handler or the module database. In
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes); // `streamed` mode no body byte is read until the handler reads it, and the handler runs only
// after authorization succeeds, so an unauthorized caller can never push an upload through
// (ADR 0042).
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path); const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => { const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({ const context: ModuleRouteHandlerContext = Object.freeze({
+64 -3
View File
@@ -1,6 +1,10 @@
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts"; import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization"; 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 { Hono } from "hono";
import type { Context } from "hono"; import type { Context } from "hono";
import { import {
@@ -9,8 +13,13 @@ import {
requirePermission, requirePermission,
} from "./authorization-middleware.js"; } from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleDisplayPageUrl } from "./module-display-path.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.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 * 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 * canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
* routing can change without touching a single line of host or module code. */ * routing can change without touching a single line of host or module code. */
readonly ui: { readonly url: string }; 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 /** 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 * 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 * `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. * nor trusts anything in this array.
*/ */
readonly permissions: readonly string[]; 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 { export interface HostModuleUiInventory {
readonly modules: readonly HostModuleUiDescriptor[]; 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 { export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same publication registry the artifact route serves from — the single accepted/publishable /** The same publication registry the artifact route serves from — the single accepted/publishable
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs * module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
@@ -116,9 +173,13 @@ async function describeModule(
evaluator: AuthorizationMiddlewareOptions["evaluator"], evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal, principal: EkklesiaPrincipal,
published: PublishableModuleUi, published: PublishableModuleUi,
registry: ModuleUiPublicationRegistry,
): Promise<HostModuleUiDescriptor> { ): Promise<HostModuleUiDescriptor> {
const display = registry.resolveDisplay(published.moduleId, published.version);
return { return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
contributions: describeModuleContributions(registry, published.moduleId),
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
id: published.moduleId, id: published.moduleId,
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions), permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
@@ -153,7 +214,7 @@ export async function describeMountableModules(
): Promise<readonly MountableModuleDescription[]> { ): Promise<readonly MountableModuleDescription[]> {
return Promise.all( return Promise.all(
registry.list().map(async (published) => ({ registry.list().map(async (published) => ({
descriptor: await describeModule(evaluator, principal, published), descriptor: await describeModule(evaluator, principal, published, registry),
name: published.name, name: published.name,
})), })),
); );
+41 -3
View File
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${moduleUiArtifactUrl(identity)}/display`; 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 * 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 * 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 * ## The request never touches the filesystem
* *
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to * `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's * components**. They are handed to the registry and, on an exact match, the already-canonical
* discovery resolved and validated is read directly. No request value is ever joined, resolved, * `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 * 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 * 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 * 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 { export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
const routes = new 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) => { routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
const published = options.registry.resolveDisplay( const published = options.registry.resolveDisplay(
c.req.param("moduleId"), c.req.param("moduleId"),
+77 -77
View File
@@ -12,9 +12,15 @@ import {
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices"; import { createDeviceStore } from "@ekklesia/core/devices";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
import {
createIdentityNotifications,
createIdentityRuntime,
type IdentityNotificationReporter,
type IdentityRateLimitOverride,
} from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import { import {
createEnvironmentModuleSecretSource,
createModuleConfigurationStore, createModuleConfigurationStore,
createModuleLifecycleStore, createModuleLifecycleStore,
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
@@ -34,7 +40,6 @@ import { type ServerType, serve } from "@hono/node-server";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { createApiApp } from "./app.js"; import { createApiApp } from "./app.js";
import { import {
type ClientAddressConfigInput,
type ClientAddressResolver, type ClientAddressResolver,
createClientAddressResolver, createClientAddressResolver,
wrapFetchWithClientAddress, wrapFetchWithClientAddress,
@@ -138,6 +143,15 @@ export interface StartApiOptions {
/** Test-only escape hatch: never set by the executable entry point. See /** Test-only escape hatch: never set by the executable entry point. See
* `IdentityRateLimitOverride` for why production must leave this unset. */ * `IdentityRateLimitOverride` for why production must leave this unset. */
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
/**
* Where a verification or reset message that could not be delivered is reported.
*
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
* seam because the *only* externally observable difference between "the relay is down" and "there
* is no such account" must be this report — every HTTP response is deliberately identical — so a
* test needs somewhere to observe the operational failure the user is never shown.
*/
readonly reportNotificationFailure?: IdentityNotificationReporter;
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the /** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
* executable entry point. See `prepareStartupModuleInventory`. */ * executable entry point. See `prepareStartupModuleInventory`. */
readonly modulesDirectoryOverride?: string; readonly modulesDirectoryOverride?: string;
@@ -179,6 +193,18 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
} }
} }
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the mail provider's transport. */
async function safeCloseMail(provider: MailProvider | undefined): Promise<void> {
if (!provider) {
return;
}
try {
await provider.close();
} catch {
// See safeCloseDatabase: never mask a failure that is already propagating.
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own /** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own
* connection pool. */ * connection pool. */
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> { async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
@@ -418,6 +444,14 @@ export async function startApi(
transport: config.databaseTransport, transport: config.databaseTransport,
}); });
// Both host providers are constructed from validated configuration **before** anything that uses
// them, and neither touches the network while being built: an S3 client opens no connection and a
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
// `undefined` for both, which is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices; let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>; let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>; let authorizationStore: ReturnType<typeof createAuthorizationStore>;
@@ -427,11 +461,22 @@ export async function startApi(
runtime = createIdentityRuntime({ runtime = createIdentityRuntime({
config: config.identity, config: config.identity,
db: databaseHandle.identity, db: databaseHandle.identity,
// Identity's two outbound messages, composed here — the one place that knows both the
// validated identity configuration and the host's mail provider. Core's identity code learns
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
notifications: createIdentityNotifications({
authBaseUrl: config.identity.baseUrl,
dashboardOrigin: config.identity.dashboardOrigin,
mail: mailProvider,
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
}),
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
}); });
authorizationStore = createAuthorizationStore(databaseHandle.authorization); authorizationStore = createAuthorizationStore(databaseHandle.authorization);
deviceStore = createDeviceStore(databaseHandle.devices); deviceStore = createDeviceStore(databaseHandle.devices);
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -448,13 +493,6 @@ export async function startApi(
// start, before a single module entrypoint is imported. A failure here fails startup rather than // 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 // 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. // 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;
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle); const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
const moduleConfigurationStore = createModuleConfigurationStore( const moduleConfigurationStore = createModuleConfigurationStore(
databaseHandle.moduleConfiguration, databaseHandle.moduleConfiguration,
@@ -482,6 +520,7 @@ export async function startApi(
); );
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider); await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -508,6 +547,7 @@ export async function startApi(
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets(); moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider); await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -531,6 +571,7 @@ export async function startApi(
}); });
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider); await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -595,6 +636,7 @@ export async function startApi(
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider); await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -611,6 +653,7 @@ export async function startApi(
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider); await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -649,6 +692,7 @@ export async function startApi(
// After the module runtime has stopped, so a `stop` hook may still use its storage // After the module runtime has stopped, so a `stop` hook may still use its storage
// facade, and before the pool closes, matching the existing ordering discipline exactly. // facade, and before the pool closes, matching the existing ordering discipline exactly.
await safeCloseStorage(storageProvider); await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a // Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
// module-stop failure can never leave the pool open. Both failures are reported together, // module-stop failure can never leave the pool open. Both failures are reported together,
@@ -692,84 +736,40 @@ export function installShutdownSignalHandlers(
process.on("SIGTERM", handleSignal); process.on("SIGTERM", handleSignal);
} }
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined { /**
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE; * Turns one deployment environment into this process's startup inputs.
if (mode === undefined) { *
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/ * Every host infrastructure value comes from `loadHostConfiguration` — the same function
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally * `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
// half-configuring proxy mode by setting only one of the three variables. * it can never disagree about what this deployment is configured to use. Only the two values that
return undefined; * describe *this listener* rather than the host's infrastructure are read here.
} */
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES; export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
return {
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
};
}
function readConfigFromEnvironment(): {
readonly input: Parameters<typeof createApiRuntimeConfig>[0]; readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production"; readonly environment: "development" | "production";
readonly moduleSecrets: ModuleSecretSource;
} { } {
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; const host = loadHostConfiguration(processEnvironment);
const trustedOrigins = trustedOriginsValue
?.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
const clientAddress = readClientAddressInputFromEnvironment();
return { return {
environment: process.env.NODE_ENV === "development" ? "development" : "production", environment: host.environment,
input: { input: {
...(clientAddress === undefined ? {} : { clientAddress }), clientAddress: host.clientAddress,
databaseTransport: { databaseTransport: host.databaseTransport,
...(process.env.EKKLESIA_DATABASE_CA === undefined databaseUrl: host.databaseUrl,
? {} host: processEnvironment.EKKLESIA_API_HOST,
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }), identity: host.identity,
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined mail: host.mail,
? {} port: processEnvironment.EKKLESIA_API_PORT,
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }), storage: host.storage,
},
databaseUrl: process.env.DATABASE_URL,
host: process.env.EKKLESIA_API_HOST,
identity: {
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: process.env.EKKLESIA_IDENTITY_SECRET,
trustedOrigins,
},
port: process.env.EKKLESIA_API_PORT,
// The one place this process turns its environment into storage configuration. The names are
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
// validator never reads `process.env` itself.
storage: {
accessKey: process.env.S3_ACCESS_KEY,
bucketPrefix: process.env.S3_BUCKET_PREFIX,
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
// storage by is not a name a browser could resolve.
deliveryEndpoint: process.env.S3_DELIVERY_ENDPOINT,
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE,
region: process.env.S3_REGION,
secretKey: process.env.S3_SECRET_KEY,
},
}, },
moduleSecrets: host.moduleSecrets,
}; };
} }
export async function main(): Promise<void> { export async function main(): Promise<void> {
const { environment, input } = readConfigFromEnvironment(); const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
const config = createApiRuntimeConfig(input, { environment }); const config = createApiRuntimeConfig(input, { environment });
// The one place this process turns its environment into module secrets. Core's factory takes the const running = await startApi(config, { moduleSecrets });
// environment as an argument and never touches `process.env` itself, preserving the rule that
// only executables read the environment.
const running = await startApi(config, {
moduleSecrets: createEnvironmentModuleSecretSource(process.env),
});
const address = running.server.address(); const address = running.server.address();
const boundPort = address && typeof address === "object" ? address.port : config.port; const boundPort = address && typeof address === "object" ? address.port : config.port;
+149 -4
View File
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
@@ -329,7 +330,7 @@ async function createGatedRouteModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0", version: "0.1.0",
@@ -373,7 +374,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -431,6 +432,39 @@ async function createFixtureModule(
async handler(request, context) { async handler(request, context) {
return Response.json({ created: true, principal: context.principal }, { status: 201 }); 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" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
version, version,
}), }),
@@ -522,6 +556,32 @@ function apiOrigin(running: RunningApi): string {
return `http://127.0.0.1:${address.port}`; 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 { interface StartConfiguredApiOptions {
readonly clientAddress?: ClientAddressConfigInput; readonly clientAddress?: ClientAddressConfigInput;
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */ /** 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; 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( async function startConfiguredApi(
url: string, url: string,
options: StartConfiguredApiOptions = {}, options: StartConfiguredApiOptions = {},
@@ -571,7 +641,7 @@ async function startConfiguredApi(
}, },
{ environment }, { environment },
); );
return startApi(config, { const running = await startApi(config, {
...("rateLimitOverride" in options ...("rateLimitOverride" in options
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) } ? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
: { rateLimitOverride: { max: 1000, window: 1 } }), : { rateLimitOverride: { max: 1000, window: 1 } }),
@@ -585,6 +655,8 @@ async function startConfiguredApi(
? {} ? {}
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }), : { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
}); });
databaseUrlByOrigin.set(apiOrigin(running), url);
return running;
} }
async function startMigratedApi( async function startMigratedApi(
@@ -763,6 +835,12 @@ async function signUp(
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers }, headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST", 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 }; 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 }); 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 () => { it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url); const admin = await openAdministrativeClient(database.url);
try { try {
+3
View File
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
// Defaults to the identity base URL: the Dashboard and the API share one origin unless a
// deployment separates them (ADR 0038).
dashboardOrigin: "https://identity.example.test",
publicSignupEnabled: false, publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: ["https://identity.example.test"], trustedOrigins: ["https://identity.example.test"],
+3 -1
View File
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -269,7 +269,9 @@ describe("Dashboard navigation — reading", () => {
// Presentation wraps the host-neutral descriptor; it never rewrites it. // Presentation wraps the host-neutral descriptor; it never rewrites it.
expect(livestream?.module).toEqual({ expect(livestream?.module).toEqual({
contributions: [],
api: { baseUrl: "/api/v1/modules/livestream" }, api: { baseUrl: "/api/v1/modules/livestream" },
display: null,
id: "livestream", id: "livestream",
permissions: [], permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/livestream" }, realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
+1 -1
View File
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
server: "./dist/server/index.js", server: "./dist/server/index.js",
}, },
id: "announcements", id: "announcements",
manifestVersion: 6, manifestVersion: 7,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+18 -7
View File
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
displayHostAssetUrls: Object.freeze({ displayHostAssetUrls: Object.freeze({
html: "file:///surface/browser/display/index.html", html: "file:///surface/browser/display/index.html",
script: "file:///surface/browser/display/app.js", script: "file:///surface/browser/display/app.js",
serviceWorker: "file:///surface/browser/display/sw.js",
}), }),
})); }));
@@ -20,21 +21,28 @@ import {
describe("display host asset loading", () => { describe("display host asset loading", () => {
beforeEach(() => readFileMock.mockReset()); 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 html = '<!doctype html><div id="ekklesia-display"></div>';
const script = "export const displayHost = true;\n"; 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(); const assets = await loadDisplayHostAssets();
expect(readFileMock.mock.calls).toEqual([ expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/display/index.html"), "utf8"], [new URL("file:///surface/browser/display/index.html"), "utf8"],
[new URL("file:///surface/browser/display/app.js"), "utf8"], [new URL("file:///surface/browser/display/app.js"), "utf8"],
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
]); ]);
expect(assets).toEqual({ expect(assets).toEqual({
html, html,
script, script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`, scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
}); });
expect(Object.isFrozen(assets)).toBe(true); 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", () => { it("rejects an empty page, script, or service worker as an unavailable host build", () => {
for (const [html, script] of [ for (const [html, script, serviceWorker] of [
["", "export {};"], ["", "export {};", "export {};"],
["<html></html>", " \n"], ["<html></html>", " \n", "export {};"],
["<html></html>", "export {};", " \n"],
] as const) { ] 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([]);
});
});
@@ -0,0 +1,177 @@
import { loadHostConfiguration } from "@ekklesia/core/host";
import { createMailProviderConfig } from "@ekklesia/core/mail";
import { createStorageProviderConfig } from "@ekklesia/core/storage";
import { describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { readConfigFromEnvironment } from "../src/server.js";
/**
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
* previously could not: each executable read its own subset of the variables, and Doctor's subset
* omitted `S3_*` and the module secrets entirely.
*
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
* validates come from one `loadHostConfiguration` call and produce identical validated
* configuration, including every default.
*/
const secretKey = "sk-example-secret-material";
const smtpPassword = "sup3r-secret-relay-password";
function environment(
overrides: Readonly<Record<string, string | undefined>> = {},
): Readonly<Record<string, string | undefined>> {
return {
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
NODE_ENV: "production",
...overrides,
};
}
const configuredInfrastructure = Object.freeze({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: smtpPassword,
EKKLESIA_SMTP_USERNAME: "[email protected]",
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
S3_ENDPOINT: "https://storage.example.org",
S3_SECRET_KEY: secretKey,
});
/** Exactly what Doctor validates, from exactly the same load the API performs. */
function doctorView(env: Readonly<Record<string, string | undefined>>) {
const host = loadHostConfiguration(env);
return {
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
};
}
function apiView(env: Readonly<Record<string, string | undefined>>) {
const { environment: resolved, input } = readConfigFromEnvironment(env);
return createApiRuntimeConfig(input, { environment: resolved });
}
describe("host configuration parity between startApi and Doctor", () => {
it("derives identical object-storage configuration, defaults included", () => {
const env = environment(configuredInfrastructure);
expect(apiView(env).storage).toEqual(doctorView(env).storage);
// Named explicitly rather than only compared, so a change to either default fails here loudly.
expect(apiView(env).storage).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://storage.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: true,
region: "us-east-1",
secretKey,
});
});
it("derives identical outbound-email configuration, defaults included", () => {
const env = environment(configuredInfrastructure);
expect(apiView(env).mail).toEqual(doctorView(env).mail);
expect(apiView(env).mail).toMatchObject({
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
// must fail in Doctor for the same reason it fails at runtime.
heloName: "mail.example.org",
host: "smtp.example.org",
port: 587,
security: "starttls",
});
});
it("agrees that an installation with neither service is complete", () => {
const env = environment();
expect(apiView(env).storage).toBeUndefined();
expect(doctorView(env).storage).toBeUndefined();
expect(apiView(env).mail).toBeUndefined();
expect(doctorView(env).mail).toBeUndefined();
});
it("carries every storage field the direct-delivery pass established", () => {
const env = environment({
...configuredInfrastructure,
S3_BUCKET_PREFIX: "rogc",
S3_DELIVERY_ENDPOINT: "https://files.example.org",
S3_FORCE_PATH_STYLE: "false",
S3_REGION: "eu-west-1",
});
expect(apiView(env).storage).toEqual(doctorView(env).storage);
expect(apiView(env).storage).toMatchObject({
bucketPrefix: "rogc",
deliveryEndpoint: "https://files.example.org",
forcePathStyle: false,
region: "eu-west-1",
});
});
it("rejects the same malformed environment with the same sanitized code", () => {
for (const [overrides, code] of [
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
[
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
],
[
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
"MAIL_USERNAME_WITHOUT_PASSWORD",
],
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
[
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
"MAIL_HELO_NAME_INVALID",
],
] as const) {
const env = environment(overrides);
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
}
});
it("resolves module secrets from the same source the runtime is given", () => {
const env = environment({
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
});
expect(
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
).toBe("module-secret-material");
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
"module-secret-material",
);
});
it("reads only this listener's own address and port outside the shared loader", () => {
const config = apiView(
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
);
expect(config.host).toBe("0.0.0.0");
expect(config.port).toBe(8080);
// Defaults, when unset, stay where they always were.
const defaults = apiView(environment());
expect(defaults.host).toBe("127.0.0.1");
expect(defaults.port).toBe(3000);
});
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
expect(() => apiView(env)).toThrowError(
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
);
});
});
+165 -4
View File
@@ -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> { * The production posture: public signup disabled, exactly as a real deployment must run — and, just
const config = createApiRuntimeConfig( * 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, databaseUrl: url,
host: "127.0.0.1", host: "127.0.0.1",
@@ -186,7 +190,10 @@ async function startApiWithoutSignup(url: string): Promise<RunningApi> {
}, },
{ environment: "development" }, { 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 { 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 () => { afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined); const admin = await openAdministrativeClient().catch(() => undefined);
if (!admin) { 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();
}
});
});
+3 -2
View File
@@ -88,7 +88,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -254,12 +254,13 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions, permissions,
version: "0.1.0", version: "0.1.0",
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [], configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -253,6 +254,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"db-facade:0000_initial", "db-facade:0000_initial",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
@@ -319,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
return { userId: body.user.id }; return { userId: body.user.id };
} }
/**
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
*
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
* module database access rather than identity, so it completes that step directly rather than
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
* `email-verification.integration.ts`.
*/
async function markEmailVerified(url: string, email: string): Promise<void> {
const client = new Client({ connectionString: url });
await client.connect();
try {
await client.query(
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
[email.trim().toLowerCase()],
);
} finally {
await client.end().catch(() => undefined);
}
}
async function signIn(origin: string, email: string, password: string): Promise<string> { async function signIn(origin: string, email: string, password: string): Promise<string> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, { const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
@@ -507,6 +530,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" }); expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
const signedUp = await signUp(origin, "[email protected]", "correct horse battery"); const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
await markEmailVerified(database.url, "[email protected]");
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery"); ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
expect(signedUp.userId).toBeDefined(); expect(signedUp.userId).toBeDefined();
}); });
+69 -5
View File
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
import { import {
DISPLAY_HOST_SCRIPT_PATH, DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
ModuleDisplayConfigurationError, ModuleDisplayConfigurationError,
moduleDisplayDescriptorUrl, moduleDisplayDescriptorUrl,
moduleDisplayPageUrl, moduleDisplayPageUrl,
@@ -51,6 +52,7 @@ const displayPermission = "announcements.display.view";
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets( const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`, `<!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.__ekklesiaDisplayHostLoaded = true;\n",
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
); );
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
@@ -101,7 +103,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}), : {}),
}, },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: fixture.display permissions: fixture.display
? [ ? [
@@ -266,11 +268,13 @@ describe("module display routes — authorization and descriptor", () => {
expect(await descriptor.json()).toEqual({ expect(await descriptor.json()).toEqual({
viewer: { kind: "authenticated", userId: viewerId }, viewer: { kind: "authenticated", userId: viewerId },
module: { module: {
contributions: [],
api: { baseUrl: moduleApiBaseUrl("announcements") }, api: { baseUrl: moduleApiBaseUrl("announcements") },
display: { display: {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }), url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
}, },
id: "announcements", id: "announcements",
page: { url: moduleDisplayPageUrl("announcements") },
permissions: [displayPermission], permissions: [displayPermission],
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") }, realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
version: "1.4.2", 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(moduleDisplayPageUrl("announcements"))).status).toBe(200);
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200); expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
expect( expect(
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] }) (
.modules, (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
).toHaveLength(1); 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 () => { 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 () => { 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 mutable = { ...source };
const { app } = await install({ assets: mutable, display: true }); const { app } = await install({ assets: mutable, display: true });
mutable.html = "<html>mutated secret</html>"; 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); 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);
}
});
}); });
+379 -4
View File
@@ -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 * body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
* each need to restate an unrelated body-size choice; the dedicated "module route body limits" * each need to restate an unrelated body-size choice; the dedicated "module route body limits"
* suite further down overrides it explicitly to prove the contract itself. */ * suite further down overrides them explicitly to prove the contract itself. */
function moduleRoute( function moduleRoute(
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number }, fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
maxBodyBytes?: number;
},
): RegisteredModuleHttpRoute { ): RegisteredModuleHttpRoute {
return Object.freeze({ maxBodyBytes: 1024, ...fields }); return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
} }
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting, /** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
@@ -1634,3 +1637,375 @@ describe("module route overlap can never reach mounting", () => {
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true); expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
}); });
}); });
/**
* Explicit, bounded streaming uploads (ADR 0042).
*
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
*/
describe("module route streamed body limits", () => {
const oneMebibyte = 1024 * 1024;
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
let sent = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= size) {
controller.close();
return;
}
const next = Math.min(chunkSize, size - sent);
sent += next;
controller.enqueue(new Uint8Array(next).fill(97));
},
});
}
function streamingApp(options: {
readonly handler: ModuleRouteHandler;
readonly maxBodyBytes: number;
readonly evaluator?: AuthorizationEvaluator;
}) {
return buildModuleApp({
...(options.evaluator ? { evaluator: options.evaluator } : {}),
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler: options.handler,
maxBodyBytes: options.maxBodyBytes,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
}
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
* "buffered then handed over". */
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
return async (request) => {
const body = request.body;
if (!body) {
return Response.json({ bytes: 0 });
}
const reader = body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
received.bytes += value.byteLength;
}
return Response.json({ bytes: received.bytes });
};
}
it("accepts a body of exactly the declared limit", async () => {
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(oneMebibyte),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ bytes: oneMebibyte });
});
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
const received = { bytes: 0 };
const counting = await countingHandler(received);
const handler = vi.fn<ModuleRouteHandler>(counting);
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(2048, 16),
duplex: "half",
headers: { "content-length": String(1025), origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
// byte of the upload reached module code.
expect(handler).not.toHaveBeenCalled();
expect(received.bytes).toBe(0);
});
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
const received = { bytes: 0 };
const { app } = streamingApp({
handler: await countingHandler(received),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { "content-length": "8", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
// It stopped promptly rather than draining the whole upload first.
expect(received.bytes).toBeLessThanOrEqual(96);
});
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
// A limit a handler can swallow is not a limit.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
try {
await request.arrayBuffer();
} catch {
// Deliberately ignored, exactly as a careless module might.
}
return Response.json({ ok: "handler won" });
});
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096, 32),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(handler).toHaveBeenCalledTimes(1);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
let released: (() => void) | undefined;
const finished = new Promise<void>((resolve) => {
released = resolve;
});
let firstChunkSeen = false;
let secondChunkQueued = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
if (!secondChunkQueued) {
secondChunkQueued = true;
controller.enqueue(new Uint8Array(16).fill(97));
return;
}
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
// buffering platform would deadlock here rather than reach the assertion below.
await finished;
controller.close();
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
const first = await reader.read();
firstChunkSeen = !first.done;
released?.();
for (;;) {
const next = await reader.read();
if (next.done) {
break;
}
}
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(firstChunkSeen).toBe(true);
expect(response.status).toBe(200);
});
it("releases an aborted upload without taking the platform down", async () => {
const aborted = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
controller.error(new Error("client went away"));
},
});
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
await request.arrayBuffer();
return Response.json({ ok: true });
});
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: aborted,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
});
it("releases a body the handler never read", async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
cancel() {
cancelled = true;
},
pull(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
},
});
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: oneMebibyte,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(200);
expect(cancelled).toBe(true);
});
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
routes: [
moduleRoute({
bodyMode: "streamed",
handler,
maxBodyBytes: 64,
method: "POST",
moduleId: "calendar",
path: "/uploads",
permission: writePermission,
}),
],
});
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
body: "a".repeat(65),
headers: { "content-length": "65", origin: trustedOrigin },
method: "POST",
});
expect(declaredOversize.status).toBe(413);
expect(evaluator.authorize).not.toHaveBeenCalled();
// A within-limit body from a denied caller is refused by authorization, and the handler — the
// only thing that ever reads a byte in streamed mode — never runs.
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(32, 8),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(handler).not.toHaveBeenCalled();
});
it("still rejects an untrusted Origin before any body handling", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: chunkedBody(4096),
duplex: "half",
headers: { origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(handler).not.toHaveBeenCalled();
});
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
const { app } = streamingApp({
handler: async () => Response.json({ ok: true }),
maxBodyBytes: 64,
});
const response = await app.request("/api/v1/modules/calendar/uploads", {
body: "secret-payload-".repeat(64),
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
method: "POST",
});
const body = await response.text();
expect(response.status).toBe(413);
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
expect(body).not.toContain("secret-payload");
expect(body).not.toContain("64");
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("still materializes a buffered body before the handler runs", async () => {
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
// body is already there and already within its declared allowance.
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
const text = await request.text();
return Response.json({ length: text.length });
});
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
bodyMode: "buffered",
handler,
maxBodyBytes: 128,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(128, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(withinLimit.status).toBe(200);
expect(await withinLimit.json()).toEqual({ length: 128 });
const overLimit = await app.request("/api/v1/modules/calendar/events", {
body: chunkedBody(129, 16),
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(overLimit.status).toBe(413);
// A buffered route's handler never sees an over-limit request at all.
expect(handler).toHaveBeenCalledTimes(1);
});
});
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
+1 -1
View File
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: "announcements", id: "announcements",
manifestVersion: 6, manifestVersion: 7,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+54 -15
View File
@@ -20,7 +20,13 @@ import {
discoverModules, discoverModules,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules"; } 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 { import {
bootstrapDisplayHost, bootstrapDisplayHost,
bootstrapHost, bootstrapHost,
@@ -121,7 +127,7 @@ async function createFixtureModule(
: {}), : {}),
}, },
id, id,
manifestVersion: 6, manifestVersion: 7,
name, name,
permissions: [ permissions: [
{ {
@@ -165,6 +171,7 @@ async function createFixtureModule(
await writeFile( await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"), path.join(moduleDirectory, "dist/ui/index.js"),
`export default { `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
async mount(container, context) { async mount(container, context) {
@@ -184,6 +191,7 @@ async function createFixtureModule(
await writeFile( await writeFile(
path.join(moduleDirectory, "dist/display/index.js"), path.join(moduleDirectory, "dist/display/index.js"),
`export default { `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
async mount(container, context) { async mount(container, context) {
@@ -270,6 +278,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
displayHostAssets: createDisplayHostAssets( displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>', '<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
"export const displayHost = true;\n", "export const displayHost = true;\n",
"export const displayWorker = true;\n",
), ),
}), }),
moduleLifecycle: { moduleLifecycle: {
@@ -310,10 +319,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
return `data:text/javascript;base64,${bytes.toString("base64")}`; 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. */ * A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
function containerSink(): { textContent: string } { * test environment.
return { textContent: "" }; *
* 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"]; 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. // 2. The URLs came from the server, not from anything the browser composed.
expect(descriptor).toEqual({ expect(descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example", apiBaseUrl: "/api/v1/modules/example",
contributions: [],
id: "example", id: "example",
permissions: ["example.records.read"], permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example", 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. // 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. // 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"]); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
// 7. And it tears down cleanly. // 7. And it tears down cleanly.
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
} finally { } finally {
await harness.stop(); 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({ expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example", apiBaseUrl: "/api/v1/modules/example",
contributions: [],
displayPageUrl: "/display/example",
id: "example", id: "example",
permissions: ["example.records.read", "example.display.view"], permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example", 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); const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader receives the unchanged five-member browser context and mounts the // 4. The generic loader receives the host-owned browser context, including the canonical
// display artifact independently of the Dashboard artifact. // display page, and mounts the display artifact independently of the Dashboard artifact.
const context = createModuleUiContext({ const context = createModuleUiContext({
config: harness.config, config: harness.config,
descriptor: bootstrap.descriptor, 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([ expect(Object.keys(context).sort()).toEqual([
"api", "api",
"contributions",
"display",
"moduleId", "moduleId",
"permissions", "permissions",
"principal", "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"]); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
} finally { } finally {
await harness.stop(); 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. // 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([]); expect(harness.moduleApiCalls).toEqual([]);
} finally { } finally {
await harness.stop(); 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, version: groupedEntry.descriptor.version,
}, },
}); });
expect(container.textContent).toBe("example:200:2"); expect(mountedText(container)).toBe("example:200:2");
await mounted.unmount(); await mounted.unmount();
// Now move it somewhere else entirely. // 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, version: movedEntry.descriptor.version,
}, },
}); });
expect(movedContainer.textContent).toBe("example:200:2"); expect(mountedText(movedContainer)).toBe("example:200:2");
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([ expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
"GET /api/v1/modules/example/records", "GET /api/v1/modules/example/records",
]); ]);
+109 -1
View File
@@ -59,6 +59,12 @@ interface ModuleFixture {
readonly id: string; readonly id: string;
readonly baseline: "authenticated" | "public" | "restricted"; 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> { 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", server: "./dist/server/index.js",
...(fixture.withUi ? { ui: "./dist/ui/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, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({ permissions: (fixture.permissions ?? []).map((permission) => ({
...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`, `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 { interface InstallOptions {
@@ -219,7 +242,9 @@ describe("module UI inventory — what it lists", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(modules).toEqual([ expect(modules).toEqual([
{ {
contributions: [],
api: { baseUrl: "/api/v1/modules/example" }, api: { baseUrl: "/api/v1/modules/example" },
display: null,
id: "example", id: "example",
permissions: [], permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/example" }, 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. // The exact key set, so a future field cannot be added without a deliberate decision here.
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([ expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
"api", "api",
"contributions",
"display",
"id", "id",
"permissions", "permissions",
"realtime", "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",
]);
});
});
+169 -10
View File
@@ -16,6 +16,8 @@ import {
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; 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 type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
@@ -25,6 +27,7 @@ import {
MODULE_UI_ASSET_PREFIX, MODULE_UI_ASSET_PREFIX,
MODULE_UI_CACHE_CONTROL, MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE, MODULE_UI_CONTENT_TYPE,
moduleContributionArtifactUrl,
moduleDisplayUiArtifactUrl, moduleDisplayUiArtifactUrl,
moduleUiArtifactUrl, moduleUiArtifactUrl,
} from "../src/module-ui-routes.js"; } 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. */ * sequence whose exact publication and loader-compatibility can be asserted. */
function artifactSource(id: string, version: string): string { function artifactSource(id: string, version: string): string {
return `export default { return `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: ${JSON.stringify(version)}, version: ${JSON.stringify(version)},
mount(container, context) { mount(container, context) {
@@ -83,6 +87,15 @@ interface ModuleFixture {
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against * 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. */ * files that genuinely exist on disk rather than against hypothetical ones. */
readonly extraFiles?: Readonly<Record<string, string>>; 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> { 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, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined 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 mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui); 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) { if (fixture.display !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display); await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
@@ -209,6 +240,7 @@ async function install(
displayHostAssets: createDisplayHostAssets( displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="display-root"></div>', '<!doctype html><div id="display-root"></div>',
"export {};\n", "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", () => { 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 * 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, * A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
* so a plain sink is enough and no DOM implementation is required. The container's type is read * root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles * rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
* against Node libs only — naming a DOM type here would be the same browser/server confusion this * DOM type here would be the same browser/server confusion this route exists to keep apart.
* route exists to keep apart.
*/ */
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"]; 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() { function hostContext() {
return { return {
api: { request: async () => new Response(null, { status: 200 }) }, api: { request: async () => new Response(null, { status: 200 }) },
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "announcements", moduleId: "announcements",
permissions: { has: () => false }, permissions: { has: () => false },
principal: { kind: "anonymous" as const }, 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" }, source: { moduleId: "announcements", url, version: "1.4.2" },
}); });
expect(container.textContent).toBe("announcements:mounted"); expect(mountedText(container)).toBe("announcements:mounted");
await mounted.unmount(); 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 () => { 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" }); ).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
+6
View File
@@ -43,6 +43,12 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0007_verified_bootstrap_owners",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+7
View File
@@ -50,6 +50,13 @@
"when": 1787543773057, "when": 1787543773057,
"tag": "0006_device_identity", "tag": "0006_device_identity",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787543773058,
"tag": "0007_verified_bootstrap_owners",
"breakpoints": true
} }
] ]
} }
+12
View File
@@ -46,6 +46,14 @@
"./devices": { "./devices": {
"types": "./dist/devices/index.d.ts", "types": "./dist/devices/index.d.ts",
"import": "./dist/devices/index.js" "import": "./dist/devices/index.js"
},
"./mail": {
"types": "./dist/mail/index.d.ts",
"import": "./dist/mail/index.js"
},
"./host": {
"types": "./dist/host/index.d.ts",
"import": "./dist/host/index.js"
} }
}, },
"scripts": { "scripts": {
@@ -62,14 +70,18 @@
"@ekklesia/contracts": "workspace:*", "@ekklesia/contracts": "workspace:*",
"better-auth": "1.6.23", "better-auth": "1.6.23",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"nodemailer": "9.0.5",
"pg": "8.22.0" "pg": "8.22.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"auth": "1.6.23", "auth": "1.6.23",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.10" "vitest": "4.1.10"
}, },
+45
View File
@@ -14,6 +14,7 @@ export type InitialPlatformOwnerErrorCode =
| "INITIAL_OWNER_EMAIL_INVALID" | "INITIAL_OWNER_EMAIL_INVALID"
| "INITIAL_OWNER_IDENTITY_NOT_CREATED" | "INITIAL_OWNER_IDENTITY_NOT_CREATED"
| "INITIAL_OWNER_IDENTITY_UNUSABLE" | "INITIAL_OWNER_IDENTITY_UNUSABLE"
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" | "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
| "INITIAL_OWNER_PASSWORD_TOO_LONG" | "INITIAL_OWNER_PASSWORD_TOO_LONG"
| "INITIAL_OWNER_PASSWORD_TOO_SHORT" | "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.", "The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
INITIAL_OWNER_IDENTITY_UNUSABLE: 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.", "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: 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.", "The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.", INITIAL_OWNER_PASSWORD_TOO_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 }; 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 { function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
if (error instanceof BootstrapIdentityError) { if (error instanceof BootstrapIdentityError) {
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") { if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
@@ -252,6 +274,29 @@ export async function createInitialPlatformOwner(
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED"); 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 { try {
const grant = const grant =
dependencies.grantOwnership ?? dependencies.grantOwnership ??
+14 -21
View File
@@ -4,35 +4,28 @@ import {
type DatabaseTransportPolicy, type DatabaseTransportPolicy,
createDatabaseTransportPolicy, createDatabaseTransportPolicy,
} from "../database/transport.js"; } from "../database/transport.js";
import { loadHostConfiguration } from "../host/index.js";
/** /**
* The one place every Core-owned executable reads its database transport policy from the process * The database transport policy for the small Core-owned executables — `pnpm migrations:plan`,
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and * `pnpm migrations:apply`, `pnpm modules:enable`, and the owner-bootstrap commands — resolved from
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`. * the process environment.
* *
* Environment reading lives here, in the executable layer, deliberately: the reusable Core * The mapping itself belongs to `loadHostConfiguration`, which `startApi` and `pnpm doctor` also
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`. * call, so `NODE_ENV` and the two TLS variables have exactly one interpretation across every entry
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string * point. Environment *reading* still happens here, in the executable layer: the reusable Core
* `"development"` relaxes anything, so an unset or misspelled value validates as production. * database and migration constructors keep taking explicit typed inputs and never touch
* `process.env`.
*/ */
export function readDatabaseTransportFromEnvironment(): { export function readDatabaseTransportFromEnvironment(): {
readonly environment: DatabaseTransportEnvironment; readonly environment: DatabaseTransportEnvironment;
readonly transport: DatabaseTransportPolicy; readonly transport: DatabaseTransportPolicy;
} { } {
const environment: DatabaseTransportEnvironment = const host = loadHostConfiguration(process.env);
process.env.NODE_ENV === "development" ? "development" : "production"; const transport = createDatabaseTransportPolicy(host.databaseTransport, {
const transport = createDatabaseTransportPolicy( environment: host.environment,
{ });
...(process.env.EKKLESIA_DATABASE_CA === undefined return { environment: host.environment, transport };
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
{ environment },
);
return { environment, transport };
} }
/** /**
+51 -52
View File
@@ -2,11 +2,12 @@ import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { import {
diagnoseEkklesia,
type DoctorOptions, type DoctorOptions,
diagnoseEkklesia,
doctorExitCode, doctorExitCode,
formatDoctorReport, formatDoctorReport,
} from "../diagnostics/index.js"; } from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
interface ModuleSdkPackageDocument { interface ModuleSdkPackageDocument {
readonly version?: unknown; readonly version?: unknown;
@@ -18,6 +19,18 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
}; };
} }
/**
* Everything Doctor needs that comes from the repository itself rather than the deployment
* environment, resolved by the caller so the environment mapping below stays pure and testable.
*/
export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
readonly platformVersion: string;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
async function readJson(pathname: string): Promise<unknown> { async function readJson(pathname: string): Promise<unknown> {
return JSON.parse(await readFile(pathname, "utf8")) as unknown; return JSON.parse(await readFile(pathname, "utf8")) as unknown;
} }
@@ -36,40 +49,40 @@ function requiredString(value: unknown): string {
return value; return value;
} }
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] { /**
const secret = process.env.EKKLESIA_IDENTITY_SECRET; * Turns one deployment environment into Doctor's inputs.
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL; *
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED; * Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; * with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
const trustedOrigins = trustedOriginsValue * deployment is configured to use. Doctor previously read its own subset here and silently omitted
? trustedOriginsValue * object storage and module secrets entirely, which made it report a correctly configured
.split(",") * installation as having neither.
.map((origin) => origin.trim()) *
.filter((origin) => origin.length > 0) * Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
: undefined; * malformed value appears in the report — which is the whole reason an operator runs this command —
* instead of throwing before a report exists.
*/
export function createDoctorOptions(
environment: HostEnvironment,
metadata: DoctorCommandMetadata,
): DoctorOptions {
const host = loadHostConfiguration(environment);
return { return {
...(baseUrl ? { baseUrl } : {}), clientAddress: host.clientAddress,
environment: process.env.NODE_ENV === "development" ? "development" : "production", databaseEnvironment: host.environment,
...(publicSignupEnabled ? { publicSignupEnabled } : {}), databaseTransport: host.databaseTransport,
...(secret ? { secret } : {}), databaseUrl: host.databaseUrl,
...(trustedOrigins ? { trustedOrigins } : {}), identityConfiguration: { ...host.identity, environment: host.environment },
}; mailConfiguration: host.mail,
} moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion,
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] { modulesDirectory: metadata.modulesDirectory,
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE; platformVersion: metadata.platformVersion,
if (mode === undefined) { },
return undefined; moduleSecrets: host.moduleSecrets,
} runtimeRange: metadata.runtimeRange,
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES; runtimeVersion: metadata.runtimeVersion,
return { storageConfiguration: host.storage,
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
}; };
} }
@@ -81,29 +94,15 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const rootPackage = rootPackageDocument(rootValue); const rootPackage = rootPackageDocument(rootValue);
const moduleSdkPackage = rootPackageDocument(moduleSdkValue); const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
const databaseEnvironment = const report = await diagnoseEkklesia(
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const); createDoctorOptions(process.env, {
const report = await diagnoseEkklesia({
clientAddress: readClientAddressFromEnvironment(),
databaseEnvironment,
databaseTransport: {
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(),
moduleDiscovery: {
moduleSdkVersion: requiredString(moduleSdkPackage.version), moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"), modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion: requiredString(rootPackage.version), platformVersion: requiredString(rootPackage.version),
},
runtimeRange: requiredString(rootPackage.engines?.node), runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node, runtimeVersion: process.versions.node,
}); }),
);
console.log(formatDoctorReport(report).join("\n")); console.log(formatDoctorReport(report).join("\n"));
return doctorExitCode(report); return doctorExitCode(report);
+22
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { PermissionRegistryError } from "../authorization/index.js"; import { PermissionRegistryError } from "../authorization/index.js";
import { import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModuleSchemas, discoverModuleSchemas,
ModuleDiscoveryError, ModuleDiscoveryError,
ModulePermissionCompositionError, ModulePermissionCompositionError,
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
return; return;
} }
// Built from the same registry the API publishes from, so this command can never disagree with
// what a running platform would actually serve. A contribution whose target capability nobody
// provides — or whose provider moved outside its declared range — is otherwise silent: the
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
const publication = createModuleUiPublicationRegistry(modules);
const compatibleContributions = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
console.log( console.log(
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`, `Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
); );
for (const discoveredModule of modules) { for (const discoveredModule of modules) {
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`); console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
for (const contribution of discoveredModule.contributions) {
const resolved = compatibleContributions.has(
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
);
console.log(
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
resolved ? "compatible provider installed" : "no compatible provider installed"
}`,
);
}
} }
} }
+14
View File
@@ -22,8 +22,22 @@ const sections: readonly DoctorSection[] = Object.freeze([
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]), section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration", "identity.client-address"]), section("Identity", ["identity.configuration", "identity.client-address"]),
section("Object storage", ["object-storage.integration", "object-storage.bindings"]), section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
section("Outbound email", ["outbound-email.configuration"]),
]); ]);
/**
* Every check id, exactly once.
*
* A check absent from `sections` is still computed and still counted toward the overall result, but
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
* cannot be added without being rendered.
*/
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
sections.flatMap((entry) => [...entry.checkIds]),
);
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({ const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
failure: "✗", failure: "✗",
pass: "✓", pass: "✓",
+203 -30
View File
@@ -4,25 +4,26 @@ import {
EMPTY_MODULE_SECRET_SOURCE, EMPTY_MODULE_SECRET_SOURCE,
isVersionCompatible, isVersionCompatible,
logicalBucketIdSchema, logicalBucketIdSchema,
type ModuleSecretSource,
moduleConfigurationKeySchema, moduleConfigurationKeySchema,
moduleIdSchema, moduleIdSchema,
type ModuleSecretSource,
permissionIdSchema, permissionIdSchema,
semanticVersionSchema, semanticVersionSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm"; import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
import type { PostgresPoolOptions } from "../database/pool.js";
import { import {
assertProductionDatabaseTransport, assertProductionDatabaseTransport,
type DatabaseTransportEnvironment,
type DatabaseTransportPolicyInput,
DatabaseTransportError,
createDatabaseTransportPolicy, createDatabaseTransportPolicy,
type DatabaseTransportEnvironment,
DatabaseTransportError,
type DatabaseTransportPolicyInput,
describeDatabaseTransport, describeDatabaseTransport,
} from "../database/transport.js"; } from "../database/transport.js";
import { import {
type ClientAddressConfigInput,
ClientAddressConfigError, ClientAddressConfigError,
type ClientAddressConfigInput,
createClientAddressConfig, createClientAddressConfig,
} from "../identity/client-address.js"; } from "../identity/client-address.js";
import { import {
@@ -32,7 +33,14 @@ import {
} from "../identity/config.js"; } from "../identity/config.js";
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js"; import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
import { identityAdapterSchema } from "../identity/schema.js"; import { identityAdapterSchema } from "../identity/schema.js";
import type { PostgresPoolOptions } from "../database/pool.js"; import {
createMailProviderConfig,
createSmtpMailProvider,
describeMailProvider,
MailConfigError,
type MailProviderConfig,
type MailProviderConfigInput,
} from "../mail/index.js";
import { import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
discoverAllMigrations, discoverAllMigrations,
@@ -43,32 +51,36 @@ import {
type PostgresMigrationDatabaseOptions, type PostgresMigrationDatabaseOptions,
planModuleMigrations, planModuleMigrations,
} from "../migrations/index.js"; } from "../migrations/index.js";
import {
createS3StorageProvider,
describeStorageProvider,
physicalBucketName,
type StorageProviderConfig,
} from "../storage/index.js";
import { import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
isConfigurationBlockedDetail,
isStorageBlockedDetail,
createModuleConfigurationDatabase, createModuleConfigurationDatabase,
createModuleConfigurationStore, createModuleConfigurationStore,
createModuleLifecycleDatabase, createModuleLifecycleDatabase,
createModuleLifecycleStore, createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModuleSchemas, discoverModuleSchemas,
discoverModules, discoverModules,
type ModuleEligibility, isConfigurationBlockedDetail,
ModulePermissionCompositionError, isStorageBlockedDetail,
type ModuleDiscoveryOptions,
type ModuleConfigurationSettings, type ModuleConfigurationSettings,
type ModuleDiscoveryOptions,
type ModuleEligibility,
type ModuleLifecycleConfiguration, type ModuleLifecycleConfiguration,
type ModuleMigrationAggregate, type ModuleMigrationAggregate,
ModulePermissionCompositionError,
type ModuleSchemaAggregate, type ModuleSchemaAggregate,
resolveModuleEligibility, resolveModuleEligibility,
} from "../modules/index.js"; } from "../modules/index.js";
import {
createS3StorageProvider,
createStorageProviderConfig,
describeStorageProvider,
physicalBucketName,
StorageConfigError,
type StorageProviderConfig,
type StorageProviderConfigInput,
} from "../storage/index.js";
export type DoctorCheckId = export type DoctorCheckId =
| "database.transport" | "database.transport"
@@ -85,7 +97,8 @@ export type DoctorCheckId =
| "identity.configuration" | "identity.configuration"
| "identity.client-address" | "identity.client-address"
| "object-storage.integration" | "object-storage.integration"
| "object-storage.bindings"; | "object-storage.bindings"
| "outbound-email.configuration";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning"; export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings"; export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
@@ -104,6 +117,7 @@ export interface DoctorReport {
export interface DoctorIdentityConfigurationOptions { export interface DoctorIdentityConfigurationOptions {
readonly baseUrl?: string | undefined; readonly baseUrl?: string | undefined;
readonly dashboardOrigin?: string | undefined;
readonly environment?: CreateIdentityConfigOptions["environment"]; readonly environment?: CreateIdentityConfigOptions["environment"];
readonly publicSignupEnabled?: string | undefined; readonly publicSignupEnabled?: string | undefined;
readonly secret?: string | undefined; readonly secret?: string | undefined;
@@ -120,12 +134,29 @@ export interface DoctorOptions {
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
readonly moduleDiscovery: ModuleDiscoveryOptions; readonly moduleDiscovery: ModuleDiscoveryOptions;
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret /** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required * matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports every
* secret as missing, which is the honest answer for a deployment that has not set it. */ * required secret as missing, which is only the honest answer for a deployment that has actually
* set none, so an executable must pass the same source it would give the runtime. */
readonly moduleSecrets?: ModuleSecretSource; readonly moduleSecrets?: ModuleSecretSource;
/** The validated host storage provider, or `undefined` when this installation has none. Doctor /**
* never receives raw environment values and never prints a credential. */ * The host's object-storage settings as the executable read them, validated here.
readonly storage?: StorageProviderConfig | undefined; *
* **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider
* is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that
* validates it and reports the sanitized code. A pre-validated option meant a bad value threw
* before a report existed, and — because the executable then had to validate it itself — meant
* `pnpm doctor` simply never passed one at all, reporting configured storage as absent.
*/
readonly storageConfiguration?: StorageProviderConfigInput | undefined;
/**
* The host's outbound mail settings as the executable read them, validated here.
*
* Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a
* malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to
* be the one that validates it and reports the sanitized code. A pre-validated option would mean
* a bad value crashed the command instead of appearing in its report.
*/
readonly mailConfiguration?: MailProviderConfigInput | undefined;
readonly runtimeRange: string; readonly runtimeRange: string;
readonly runtimeVersion: string; readonly runtimeVersion: string;
} }
@@ -166,6 +197,9 @@ export interface DoctorDependencies {
/** Probes the configured storage provider for reachability. Behind the dependency boundary like /** Probes the configured storage provider for reachability. Behind the dependency boundary like
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */ * every other I/O Doctor performs, so the whole report stays exercisable without a network. */
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>; readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
/** Opens an SMTP connection, authenticates when credentials are configured, and closes —
* **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */
readonly probeMail: (config: MailProviderConfig) => Promise<void>;
} }
const doctorPostgresTimeoutMs = 10_000; const doctorPostgresTimeoutMs = 10_000;
@@ -220,6 +254,22 @@ async function probeStorageForRuntime(config: StorageProviderConfig): Promise<vo
} }
} }
/**
* Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind.
*
* `verify()` performs the SMTP handshake and, where credentials are configured, an authentication
* exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every
* time an operator ran it would be its own incident.
*/
async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const provider = createSmtpMailProvider(config);
try {
await provider.verify();
} finally {
await provider.close().catch(() => undefined);
}
}
const productionDependencies: DoctorDependencies = Object.freeze({ const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry, composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase, createMigrationDatabase: createPostgresMigrationDatabase,
@@ -227,6 +277,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
discoverModules, discoverModules,
discoverSchemas: discoverModuleSchemas, discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations, planMigrations: planModuleMigrations,
probeMail: probeMailForRuntime,
probeStorage: probeStorageForRuntime, probeStorage: probeStorageForRuntime,
readModuleConfiguration: readModuleConfigurationForRuntime, readModuleConfiguration: readModuleConfigurationForRuntime,
readModuleLifecycle: readModuleLifecycleForRuntime, readModuleLifecycle: readModuleLifecycleForRuntime,
@@ -266,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) }); return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
} }
/**
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
* — how many of them have a compatible provider among the installed set.
*
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
* capability nobody provides, or whose provider moved outside its declared range, is simply not
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
* are derived from the same publication registry the API serves from, so this can never disagree
* with what the running platform would actually publish.
*
* Static only, like every other Doctor check: it reports what the *manifests* support and never
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
*/
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] { function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
const publication = createModuleUiPublicationRegistry(modules);
const compatible = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
return modules.map((module) => { return modules.map((module) => {
const id = module.manifest.id; const id = module.manifest.id;
const version = module.manifest.version; const version = module.manifest.version;
@@ -276,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
) { ) {
throw new TypeError("Unsafe module diagnostic value."); throw new TypeError("Unsafe module diagnostic value.");
} }
const declared = module.contributions.length;
if (declared === 0) {
return `${id} ${version}`; return `${id} ${version}`;
}
const resolved = module.contributions.filter((contribution) =>
compatible.has(`${id}\u0000${contribution.id}`),
).length;
return `${id} ${version} · ${declared} browser contribution${
declared === 1 ? "" : "s"
} (${resolved} with a compatible provider)`;
}); });
} }
@@ -813,6 +892,23 @@ export async function diagnoseEkklesia(
options: DoctorOptions, options: DoctorOptions,
dependencies: DoctorDependencies = productionDependencies, dependencies: DoctorDependencies = productionDependencies,
): Promise<DoctorReport> { ): Promise<DoctorReport> {
// Resolved first, before module eligibility is decided: whether a provider is configured is an
// input to whether a module that declares a bucket can run, so validating it later would mean
// deciding eligibility against a question that had not been answered yet.
const hostEnvironment =
options.databaseEnvironment === "development" ? "development" : "production";
let storageConfig: StorageProviderConfig | undefined;
let storageConfigurationError: string | undefined;
try {
storageConfig = createStorageProviderConfig(options.storageConfiguration, {
environment: hostEnvironment,
});
} catch (error) {
// The sanitized code only — never the endpoint, the access key, or the secret key.
storageConfigurationError =
error instanceof StorageConfigError ? error.code : "STORAGE_CONFIGURATION_INVALID";
}
let runtimeCheck: DoctorCheck; let runtimeCheck: DoctorCheck;
try { try {
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange) runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
@@ -922,7 +1018,7 @@ export async function diagnoseEkklesia(
discoveredModules, discoveredModules,
configuration, configuration,
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings }, { secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
{ providerConfigured: options.storage !== undefined }, { providerConfigured: storageConfig !== undefined },
); );
eligibilityEntries = resolution.entries; eligibilityEntries = resolution.entries;
lifecycleCheck = moduleLifecycleCheck(resolution.entries); lifecycleCheck = moduleLifecycleCheck(resolution.entries);
@@ -1037,7 +1133,20 @@ export async function diagnoseEkklesia(
// whether modules run. Conflating them would let a momentary network blip read as a module // whether modules run. Conflating them would let a momentary network blip read as a module
// problem (ADR 0030). // problem (ADR 0030).
let storageCheck: DoctorCheck; let storageCheck: DoctorCheck;
if (!options.storage) { if (storageConfigurationError !== undefined) {
// A failure, unlike an unreachable provider: this is not a network condition an operator can
// wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is
// genuinely not ready, and saying so is what stops the operator from discovering it at boot.
storageCheck = freezeCheck(
"object-storage.integration",
"failure",
"Object storage configuration invalid",
[
`The object-storage settings were rejected: ${storageConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.",
],
);
} else if (!storageConfig) {
storageCheck = freezeCheck( storageCheck = freezeCheck(
"object-storage.integration", "object-storage.integration",
"skipped", "skipped",
@@ -1046,14 +1155,14 @@ export async function diagnoseEkklesia(
); );
} else { } else {
try { try {
await dependencies.probeStorage(options.storage); await dependencies.probeStorage(storageConfig);
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [ storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
describeStorageProvider(options.storage), describeStorageProvider(storageConfig),
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a // Said plainly rather than implied: this probe used the endpoint Core talks to, and a
// separate browser-facing origin is by definition something a browser reaches and this // separate browser-facing origin is by definition something a browser reaches and this
// process may not. Reporting "reachable" without that caveat would let an operator conclude // process may not. Reporting "reachable" without that caveat would let an operator conclude
// direct delivery works when only half of it was tested. // direct delivery works when only half of it was tested.
...(options.storage.deliveryEndpoint === options.storage.endpoint ...(storageConfig.deliveryEndpoint === storageConfig.endpoint
? [] ? []
: [ : [
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.", "The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
@@ -1068,7 +1177,7 @@ export async function diagnoseEkklesia(
"warning", "warning",
"Object storage unreachable", "Object storage unreachable",
[ [
describeStorageProvider(options.storage), describeStorageProvider(storageConfig),
"The configured storage service did not respond. Modules already running are unaffected.", "The configured storage service did not respond. Modules already running are unaffected.",
], ],
); );
@@ -1076,11 +1185,74 @@ export async function diagnoseEkklesia(
} }
const storageBindings = discoveredModules const storageBindings = discoveredModules
? storageBindingsCheck(discoveredModules, eligibilityEntries, options.storage) ? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig)
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [ : freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
"Complete module discovery is required.", "Complete module discovery is required.",
]); ]);
// Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not
// answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in
// the platform sends email yet, so an installation without SMTP is complete rather than degraded
// (ADR 0037).
let mailCheck: DoctorCheck;
let mailConfig: MailProviderConfig | undefined;
let mailConfigurationError: string | undefined;
try {
mailConfig = createMailProviderConfig(options.mailConfiguration, {
environment: hostEnvironment,
});
} catch (error) {
// The sanitized code only — never the host, the username, or the password.
mailConfigurationError =
error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID";
}
if (mailConfigurationError !== undefined) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"failure",
"Outbound email configuration invalid",
[
`The SMTP settings were rejected: ${mailConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.",
],
);
} else if (!mailConfig) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"skipped",
"Outbound email not configured",
[
"No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.",
],
);
} else {
try {
await dependencies.probeMail(mailConfig);
mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [
describeMailProvider(mailConfig),
// Said plainly: this connected and, where credentials are set, authenticated. It did not
// send anything, so it proves the relay accepts this deployment — not that a message would
// reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization
// policy and cannot be established from here.
"Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.",
]);
} catch {
// A warning, never a failure, exactly as for storage: the configuration is valid and nothing
// in the platform depends on mail yet, so a relay that is briefly unreachable — or one that
// declines a bare connectivity check — must not make the deployment read as broken.
mailCheck = freezeCheck(
"outbound-email.configuration",
"warning",
"Outbound email unreachable",
[
describeMailProvider(mailConfig),
"The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.",
],
);
}
}
return publishReport([ return publishReport([
runtimeCheck, runtimeCheck,
moduleCheck, moduleCheck,
@@ -1097,5 +1269,6 @@ export async function diagnoseEkklesia(
clientAddressDoctorCheck, clientAddressDoctorCheck,
storageCheck, storageCheck,
storageBindings, storageBindings,
mailCheck,
]); ]);
} }
+162
View File
@@ -0,0 +1,162 @@
import type { ModuleSecretSource } from "@ekklesia/contracts";
import type { DatabaseTransportPolicyInput } from "../database/index.js";
import type { ClientAddressConfigInput } from "../identity/index.js";
import type { MailProviderConfigInput } from "../mail/index.js";
import { createEnvironmentModuleSecretSource } from "../modules/index.js";
import type { StorageProviderConfigInput } from "../storage/index.js";
/**
* A deployment's environment, passed **as an argument**.
*
* `process.env` satisfies this, but nothing here reads it: the repository rule that only
* executables touch the process environment is preserved exactly as
* `createEnvironmentModuleSecretSource` preserves it, and it is what makes every variable this
* module understands testable without mutating global state.
*/
export type HostEnvironment = Readonly<Record<string, string | undefined>>;
export type HostEnvironmentName = "development" | "production";
/**
* The identity settings as they appear in the environment — strings, unvalidated.
*
* Typed as `string | undefined` rather than `unknown` so this one object can be handed to both
* `createIdentityConfig` (which validates it) and Doctor's `identityConfiguration` (which validates
* it and reports the sanitized code) without either side re-deriving it.
*/
export interface HostIdentityEnvironmentInput {
readonly baseUrl: string | undefined;
/** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is
* served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */
readonly dashboardOrigin: string | undefined;
readonly publicSignupEnabled: string | undefined;
readonly secret: string | undefined;
readonly trustedOrigins: readonly string[] | undefined;
}
/**
* Every host infrastructure input an Ekklesia executable resolves from its environment.
*
* Deliberately **unvalidated**: each consumer runs the same Core validator over these values, but
* they do so at different moments and want different failure behaviour. `startApi` wants a
* malformed value to abort startup; Doctor wants it reported as a sanitized code in a check. If this
* type were pre-validated, Doctor could never report a configuration error — the load would have
* thrown before a report existed.
*
* The API listener's own `host`/`port` are deliberately absent: they describe the API process, not
* the host infrastructure a diagnostic evaluates, and Doctor has no use for them.
*/
export interface HostConfiguration {
/** `"development"` only when `NODE_ENV` is exactly that string, so an unset or misspelled value
* validates as production and never silently relaxes a security rule. */
readonly environment: HostEnvironmentName;
/** `undefined` when no mode is set at all, which selects direct mode. */
readonly clientAddress: ClientAddressConfigInput | undefined;
readonly databaseTransport: DatabaseTransportPolicyInput;
readonly databaseUrl: string | undefined;
readonly identity: HostIdentityEnvironmentInput;
readonly mail: MailProviderConfigInput;
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
* declared keys — there is no arbitrary variable lookup. */
readonly moduleSecrets: ModuleSecretSource;
readonly storage: StorageProviderConfigInput;
}
function splitList(value: string | undefined): readonly string[] | undefined {
if (value === undefined) {
return undefined;
}
return value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
/** Omitted entirely when no mode is set: reading `header`/`trustedProxies` only when `mode` is
* actually present keeps an operator from half-configuring proxy mode by setting one of three
* variables. */
function readClientAddress(environment: HostEnvironment): ClientAddressConfigInput | undefined {
const mode = environment.EKKLESIA_CLIENT_ADDRESS_MODE;
if (mode === undefined) {
return undefined;
}
return {
header: environment.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: splitList(environment.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES),
};
}
/**
* The one place any Ekklesia executable turns a deployment environment into host infrastructure
* configuration.
*
* It exists because the alternative was demonstrably wrong: `apps/api` and `pnpm doctor` each read
* their own subset of the same variables, and the subsets drifted. Doctor never read `S3_*` at all,
* so a correctly configured installation was told its object storage was "not configured" and every
* module requiring a bucket was reported blocked — while `startApi`, from the identical environment,
* bound those buckets and ran the modules. A diagnostic that disagrees with the runtime it
* diagnoses is worse than no diagnostic.
*
* So there is now exactly one mapping from variable names to typed inputs, and both executables call
* it. Validation stays where it already lives — `createIdentityConfig`,
* `createDatabaseTransportPolicy`, `createStorageProviderConfig`, `createMailProviderConfig` — and
* is deliberately *not* performed here, so each caller keeps its own failure behaviour.
*
* Nothing is read from `process.env`, and nothing here logs, prints, or serializes: the returned
* object carries the identity secret, both storage credentials, and the SMTP password.
*/
export function loadHostConfiguration(environment: HostEnvironment): HostConfiguration {
return Object.freeze({
clientAddress: readClientAddress(environment),
databaseTransport: {
...(environment.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: environment.EKKLESIA_DATABASE_CA }),
...(environment.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: environment.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: environment.DATABASE_URL,
environment: environment.NODE_ENV === "development" ? "development" : "production",
identity: {
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: environment.EKKLESIA_IDENTITY_SECRET,
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
},
// Names follow the platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a
// provider's. Absent host means this installation sends no outbound email, which is a complete
// deployment (ADR 0037); username and password are optional as a pair, because a relay
// authorized by source IP has neither.
mail: {
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: environment.EKKLESIA_SMTP_FROM_NAME,
// The name this client announces in `EHLO`. Optional, and unset is the ordinary case: it
// matters only where the machine's own hostname is not a fully qualified name the relay will
// accept.
heloName: environment.EKKLESIA_SMTP_HELO_NAME,
host: environment.EKKLESIA_SMTP_HOST,
password: environment.EKKLESIA_SMTP_PASSWORD,
port: environment.EKKLESIA_SMTP_PORT,
security: environment.EKKLESIA_SMTP_SECURITY,
username: environment.EKKLESIA_SMTP_USERNAME,
},
moduleSecrets: createEnvironmentModuleSecretSource(environment),
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
// endpoint means this installation has no object storage, which is a complete deployment
// (ADR 0030).
storage: {
accessKey: environment.S3_ACCESS_KEY,
bucketPrefix: environment.S3_BUCKET_PREFIX,
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
// storage by is not a name a browser could resolve.
deliveryEndpoint: environment.S3_DELIVERY_ENDPOINT,
endpoint: environment.S3_ENDPOINT,
forcePathStyle: environment.S3_FORCE_PATH_STYLE,
region: environment.S3_REGION,
secretKey: environment.S3_SECRET_KEY,
},
});
}
+7
View File
@@ -0,0 +1,7 @@
export {
type HostConfiguration,
type HostEnvironment,
type HostEnvironmentName,
type HostIdentityEnvironmentInput,
loadHostConfiguration,
} from "./configuration.js";
+80 -1
View File
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
| "BASE_URL_INVALID" | "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING" | "BASE_URL_MISSING"
| "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"
| "DASHBOARD_ORIGIN_INVALID"
| "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"
| "DASHBOARD_ORIGIN_UNTRUSTED"
| "PUBLIC_SIGNUP_ENABLED_INVALID" | "PUBLIC_SIGNUP_ENABLED_INVALID"
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" | "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
| "SECRET_LOW_ENTROPY" | "SECRET_LOW_ENTROPY"
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
BASE_URL_LOOPBACK_IN_PRODUCTION: BASE_URL_LOOPBACK_IN_PRODUCTION:
"The identity base URL must not resolve to a loopback host in production.", "The identity base URL must not resolve to a loopback host in production.",
BASE_URL_MISSING: "The identity base URL is required.", BASE_URL_MISSING: "The identity base URL is required.",
DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.",
DASHBOARD_ORIGIN_INVALID:
"The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.",
DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION:
"The Dashboard origin must not resolve to a loopback host in production.",
DASHBOARD_ORIGIN_UNTRUSTED:
"The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.",
PUBLIC_SIGNUP_ENABLED_INVALID: PUBLIC_SIGNUP_ENABLED_INVALID:
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".', 'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION: PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
export interface IdentityConfig { export interface IdentityConfig {
readonly baseUrl: string; readonly baseUrl: string;
/**
* The browser origin an emailed verification or password-reset link sends a person to.
*
* Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API
* are served from one origin. It exists as its own value because they are not the same *concept* —
* `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and
* a deployment may separate them (an API at `https://api.example.org`, the Dashboard at
* `https://dash.example.org`). It is deliberately **not** the public church website origin: this
* is the authenticated Dashboard host and nothing else.
*
* Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so
* a misconfiguration fails at startup rather than producing emailed links every recipient is
* refused at.
*/
readonly dashboardOrigin: string;
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) — /** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
* public signup never becomes reachable merely because this input was omitted, misspelled, or * public signup never becomes reachable merely because this input was omitted, misspelled, or
* loosely parsed. Development and test composition may enable it explicitly; production * loosely parsed. Development and test composition may enable it explicitly; production
@@ -176,6 +202,8 @@ export interface IdentityConfig {
export interface IdentityConfigInput { export interface IdentityConfigInput {
readonly baseUrl?: unknown; readonly baseUrl?: unknown;
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
readonly dashboardOrigin?: unknown;
readonly publicSignupEnabled?: unknown; readonly publicSignupEnabled?: unknown;
readonly secret?: unknown; readonly secret?: unknown;
readonly trustedOrigins?: unknown; readonly trustedOrigins?: unknown;
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
return url.origin; return url.origin;
} }
/**
* Resolves the origin an emailed link points a browser at.
*
* Unset falls back to the identity base URL — the same-origin production shape — rather than
* failing, because a deployment that has not separated its Dashboard from its API has nothing to
* configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it
* must be an origin Better Auth already trusts, because Better Auth validates the emailed callback
* against `trustedOrigins` before redirecting. Checking it here converts a broken link every
* recipient would hit into a startup failure the operator sees once.
*/
function validateDashboardOrigin(
dashboardOrigin: unknown,
baseUrl: string,
trustedOrigins: readonly string[],
environment: IdentityEnvironment,
): string {
if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") {
return baseUrl;
}
const url = parseCanonicalHttpOrigin(dashboardOrigin);
if (!url) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION");
}
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION");
}
}
// `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the
// base URL's origin to its trusted set (`getTrustedOrigins`, pinned release).
if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED");
}
return url.origin;
}
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string { function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
if (typeof origin !== "string" || origin.length === 0) { if (typeof origin !== "string" || origin.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
@@ -355,7 +422,19 @@ export function createIdentityConfig(
const secret = validateSecret(input.secret); const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment); const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment); const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
const dashboardOrigin = validateDashboardOrigin(
input.dashboardOrigin,
baseUrl,
trustedOrigins,
environment,
);
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment); const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins }); return Object.freeze({
baseUrl,
dashboardOrigin,
publicSignupEnabled,
secret,
trustedOrigins,
});
} }
+1
View File
@@ -2,4 +2,5 @@ export * from "./bootstrap-identity.js";
export * from "./client-address.js"; export * from "./client-address.js";
export * from "./config.js"; export * from "./config.js";
export * from "./database.js"; export * from "./database.js";
export * from "./notifications.js";
export * from "./runtime.js"; export * from "./runtime.js";
+291
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/**
* 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);
}
+119 -5
View File
@@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import type { IdentityConfig } from "./config.js"; import type { IdentityConfig } from "./config.js";
import type { IdentityDrizzleDatabase } from "./database.js"; import type { IdentityDrizzleDatabase } from "./database.js";
import type { IdentityNotifications } from "./notifications.js";
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js"; import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
export type IdentityRuntimeErrorCode = export type IdentityRuntimeErrorCode =
@@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
*/ */
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error."; const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
/**
* One hour for both, matching the pinned release's own defaults but stated rather than inherited.
*
* Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough
* that someone who reads their mail after lunch is not sent back to the start. The message bodies
* state the same hour, so the two can be checked against each other.
*/
const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600;
const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600;
/** /**
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which * Explicitly replaces Better Auth's default logger so its own message text and arguments — which
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a * may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
@@ -82,10 +93,29 @@ export interface CreateIdentityRuntimeOptions {
readonly config: IdentityConfig; readonly config: IdentityConfig;
readonly db: IdentityDrizzleDatabase; readonly db: IdentityDrizzleDatabase;
/** /**
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test * How verification and password-reset messages are delivered.
* composition that issues many such requests quickly. Rate limiting itself is always enabled; *
* production composition must leave this unset so Better Auth's own strict built-in default * Injected rather than constructed here, for the same reason the database is: this file composes
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified. * Better Auth and must not decide what a message says or how it travels. Omitting it is a
* complete, valid deployment — email verification and password recovery are then configured
* exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while
* every HTTP response keeps its usual shape (ADR 0038).
*
* Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An
* installation with no mail cannot verify a new account, which is why the initial platform owner
* is created already verified by a trusted operator command rather than by an email round trip.
*/
readonly notifications?: IdentityNotifications;
/**
* A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats
* more strictly than its global default, used only by test composition that issues many such
* requests quickly. Rate limiting itself is always enabled; production composition must leave this
* unset so Better Auth's own built-in defaults apply unmodified — 3 requests / 10 seconds on
* sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification
* resends, which are the two paths an attacker could otherwise use to send mail repeatedly.
*
* That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single
* deployment and is deliberately **not** claimed to be a distributed one.
*/ */
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
} }
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
enabled: true, enabled: true,
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128` // Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
// in the pinned release): an implicit default can change silently on a future Better Auth // in the pinned release): an implicit default can change silently on a future Better Auth
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. // upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint
// enforces the identical pair from the same configuration, so there is deliberately no
// separate reset-time password policy.
maxPasswordLength: 128, maxPasswordLength: 128,
minPasswordLength: 8, minPasswordLength: 8,
/**
* A person may not hold a session until their address is verified (ADR 0038).
*
* In the pinned release this is enforced *after* the password is checked and *before* any
* session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set.
* That is the whole reason no "unverified principal" exists anywhere in this platform — a
* correct password on an unverified account produces no session at all, so there is no
* half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to
* describe only anonymous, authenticated human, and device.
*/
requireEmailVerification: true,
/** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the
* password bounds are pinned. */
resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS,
/**
* Every existing session for that account is deleted when its password is reset.
*
* Password reset is the recovery path someone uses precisely when they suspect their
* credential is compromised, and leaving the attacker's session alive would make the recovery
* cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned
* release), not a Core reach into the session table.
*
* It deletes *all* of that human's sessions, including the browser that performed the reset —
* so the completion state returns to the sign-in form rather than pretending to be signed in.
* Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no
* password, and are entirely unaffected (ADR 0036).
*/
revokeSessionsOnPasswordReset: true,
...(options.notifications
? { sendResetPassword: options.notifications.sendResetPassword }
: {}),
},
/**
* Verification is configured identically whether or not mail is deliverable, so the policy an
* installation enforces never depends on the state of its relay.
*/
emailVerification: {
/**
* No session is minted from following an emailed link.
*
* A verification URL travels through mail infrastructure and is routinely fetched by things
* that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning
* that GET into a logged-in browser session would hand a session to whoever, or whatever,
* dereferenced the link. Verification therefore only flips the flag, and the person signs in
* afterwards through the ordinary form.
*/
autoSignInAfterVerification: false,
/** Pinned rather than left on the release's `|| 3600` fallback. */
expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS,
/**
* A sign-in attempt against an unverified account re-sends the verification message.
*
* This is the resend path that costs nothing to secure: reaching it requires the correct
* password, so it reveals nothing an attacker did not already know, and it means the common
* case — the first link expired — is handled by the thing the person was already trying to do.
* The standalone `/send-verification-email` endpoint remains available for the case where they
* would rather not retype a password, and is enumeration-safe on its own.
*/
sendOnSignIn: true,
/**
* Pinned rather than left on the release's "follow `requireEmailVerification`" fallback.
*
* It reaches nothing in production, where signup is refused outright — but stating it means a
* development or test deployment that does enable signup behaves the way this platform
* intends, rather than the way an inherited default happens to.
*/
sendOnSignUp: true,
...(options.notifications
? { sendVerificationEmail: options.notifications.sendVerificationEmail }
: {}),
}, },
logger: { logger: {
disabled: false, disabled: false,
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
...(rateLimitOverride ...(rateLimitOverride
? { ? {
customRules: { customRules: {
// The four paths Better Auth applies a stricter-than-global default to. Production
// composition never sets this override, so those defaults — 3 requests / 10 seconds
// on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an
// email — remain in force there, unmodified.
"/request-password-reset": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
"/send-verification-email": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
"/sign-in/email": { "/sign-in/email": {
max: rateLimitOverride.max ?? 100, max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10, window: rateLimitOverride.window ?? 10,
+285
View File
@@ -0,0 +1,285 @@
import {
isValidMailAddress,
isValidMailDisplayName,
MAIL_MAX_DISPLAY_NAME_LENGTH,
} from "./message.js";
export type MailConfigErrorCode =
| "MAIL_FROM_ADDRESS_INVALID"
| "MAIL_FROM_ADDRESS_MISSING"
| "MAIL_FROM_NAME_INVALID"
| "MAIL_HELO_NAME_INVALID"
| "MAIL_HOST_INVALID"
| "MAIL_PASSWORD_WITHOUT_USERNAME"
| "MAIL_PORT_INVALID"
| "MAIL_SECURITY_INVALID"
| "MAIL_SECURITY_INSECURE_IN_PRODUCTION"
| "MAIL_USERNAME_WITHOUT_PASSWORD";
const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object.freeze({
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
MAIL_HELO_NAME_INVALID:
"The SMTP HELO name must be a fully qualified domain name, with no scheme, port, brackets, or spaces.",
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
MAIL_PASSWORD_WITHOUT_USERNAME:
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
MAIL_PORT_INVALID: "The SMTP port must be an integer between 1 and 65535.",
MAIL_SECURITY_INSECURE_IN_PRODUCTION:
"A production SMTP transport must use implicit TLS or STARTTLS; plaintext is development-only.",
MAIL_SECURITY_INVALID:
'The SMTP security mode must be exactly "implicit-tls", "starttls", or "plaintext".',
MAIL_USERNAME_WITHOUT_PASSWORD:
"An SMTP username requires a password; supply both or neither for an unauthenticated relay.",
});
/**
* Never carries the host, port, username, password, or sender — only a fixed code and message.
* Matching `StorageConfigError` exactly, and for the same sharper reason: one of the values this
* validates is a credential.
*/
export class MailConfigError extends Error {
readonly code: MailConfigErrorCode;
constructor(code: MailConfigErrorCode) {
super(mailConfigMessages[code]);
this.name = "MailConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export type MailEnvironment = "development" | "production";
/**
* How the connection is protected, stated explicitly rather than inferred.
*
* A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear
* and hope STARTTLS is offered", and a server that quietly stops advertising STARTTLS — or an
* attacker who strips it — turns that into a plaintext session carrying a password, with nothing in
* the configuration that changed. Naming the mode makes the downgrade impossible to reach by
* accident: `starttls` **requires** the upgrade and fails the send if it is unavailable.
*
* - `implicit-tls` — TLS from the first byte, conventionally port 465.
* - `starttls` — plaintext connect, then a *mandatory* upgrade, conventionally 587 (or 25 on a
* relay). This is what Google Workspace SMTP Relay and most modern providers expect.
* - `plaintext` — no TLS at all. Development and a trusted local relay only; production refuses it.
*/
export const MAIL_SECURITY_MODES = ["implicit-tls", "starttls", "plaintext"] as const;
export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
export interface MailProviderConfigInput {
readonly host?: string | undefined;
readonly heloName?: string | undefined;
readonly port?: string | number | undefined;
readonly security?: string | undefined;
readonly username?: string | undefined;
readonly password?: string | undefined;
readonly fromAddress?: string | undefined;
readonly fromName?: string | undefined;
}
/**
* The validated host mail configuration.
*
* **No feature or module ever sees this.** It reaches Core's provider and nothing else; the
* `MailProvider` seam speaks only in validated messages, and there is no accessor anywhere that
* could return the host, the port, or the password (ADR 0037).
*/
export interface MailProviderConfig {
readonly host: string;
/**
* The name this client identifies itself as in the SMTP `EHLO` command, when the deployment sets
* one.
*
* This exists because of a real failure, and the failure is generic rather than tied to any one
* provider. An SMTP client must greet the server with its own identity, and when nothing is
* configured Nodemailer derives it from the machine's hostname — falling back to the address
* literal `[127.0.0.1]` whenever that hostname is not fully qualified, which is the normal state
* of a container or a VM nobody has named. Some relays refuse that greeting outright, closing the
* connection with a `421` before any message is offered, which surfaces here as a bare
* `MAIL_PROVIDER_UNAVAILABLE` and looks indistinguishable from the relay being down.
*
* Absent means Nodemailer's own derivation is used, unchanged. It is deliberately not defaulted to
* the sender's domain: a host whose machine hostname *is* a valid FQDN may already be greeting
* correctly — some relays check it against the connecting address's reverse DNS — and silently
* replacing a working identity would trade one deployment's bug for another's.
*/
readonly heloName?: string;
readonly port: number;
readonly security: MailSecurityMode;
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
* Workspace arrangement — has neither. */
readonly username?: string;
readonly password?: string;
readonly fromAddress: string;
readonly fromName?: string;
}
/** Conventional defaults per mode, so an operator who names a mode does not also have to remember a
* port number. Any port may still be set explicitly. */
const DEFAULT_PORTS: Readonly<Record<MailSecurityMode, number>> = Object.freeze({
"implicit-tls": 465,
plaintext: 25,
starttls: 587,
});
/** A bare hostname or IP literal: no scheme, no port, no path, no credentials. Anything richer is a
* connection string, and a connection string is where a password ends up in a log. */
const HOST_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/iu;
const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/u;
function validateHost(host: string): string {
const trimmed = host.trim();
if (trimmed.length === 0 || (!HOST_PATTERN.test(trimmed) && !IPV4_PATTERN.test(trimmed))) {
throw new MailConfigError("MAIL_HOST_INVALID");
}
return trimmed;
}
/**
* A fully qualified domain name, and deliberately nothing else.
*
* Stricter than `HOST_PATTERN` on purpose, in two ways. It requires **at least one dot**, because a
* single unqualified label is precisely the input Nodemailer already refuses to use — configuring
* one would reproduce the failure this setting exists to fix. And it requires the final label to
* begin with a letter, which rejects a bare IPv4 address: `EHLO 192.0.2.1` is not valid SMTP syntax,
* and the bracketed address-literal form that *is* valid is the greeting the affected relays reject.
*
* Everything the requirement rules out falls out of the anchors and the character class rather than
* from a list of special cases: a scheme, a port, a path, whitespace, brackets, a comma, a
* carriage return or line feed, and any other prose all fail to match.
*/
const HELO_NAME_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
function validateHeloName(heloName: string): string {
if (!HELO_NAME_PATTERN.test(heloName)) {
throw new MailConfigError("MAIL_HELO_NAME_INVALID");
}
return heloName;
}
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
return DEFAULT_PORTS[security];
}
const parsed = typeof port === "number" ? port : Number(port.trim());
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new MailConfigError("MAIL_PORT_INVALID");
}
return parsed;
}
function validateSecurity(
security: string | undefined,
environment: MailEnvironment,
): MailSecurityMode {
// Defaulting to STARTTLS rather than to whatever the port implies: an omitted mode must never be
// the one that sends a password in the clear.
const mode = security === undefined || security.trim() === "" ? "starttls" : security.trim();
if (!(MAIL_SECURITY_MODES as readonly string[]).includes(mode)) {
throw new MailConfigError("MAIL_SECURITY_INVALID");
}
if (environment === "production" && mode === "plaintext") {
throw new MailConfigError("MAIL_SECURITY_INSECURE_IN_PRODUCTION");
}
return mode as MailSecurityMode;
}
/**
* Validates the host's outbound email configuration, or reports that this installation has none.
*
* **Outbound email is optional.** An installation with no `host` returns `undefined`, and that is a
* complete, valid deployment — the platform starts, every existing feature works, and only a future
* feature that actually needs to send is affected, which it discovers as a structured
* `MAIL_NOT_CONFIGURED` rather than a crash (ADR 0037).
*
* Credentials are validated as a **pair**. Many SMTP examples assume a username and password, but a
* relay authorized by source IP — which is how Google Workspace SMTP Relay is commonly deployed —
* has neither, so requiring them would make the intended production arrangement unconfigurable.
* Half a pair is always a mistake, though: it means someone set one variable and expected
* authentication, so it fails loudly instead of silently connecting anonymously.
*/
export function createMailProviderConfig(
input: MailProviderConfigInput | undefined,
options: { readonly environment?: MailEnvironment } = {},
): MailProviderConfig | undefined {
const host = input?.host?.trim();
if (host === undefined || host === "") {
return undefined;
}
const environment = options.environment ?? "production";
const security = validateSecurity(input?.security, environment);
const validatedHost = validateHost(host);
const port = validatePort(input?.port, security);
// Validated exactly as supplied, with no trimming. Every other value here tolerates surrounding
// whitespace as a copy-paste artefact; this one must not, because a stray character in the value
// goes straight onto the wire in a protocol command. It is refused rather than repaired, the same
// rule an address follows.
const heloNameInput = input?.heloName;
const heloName =
heloNameInput === undefined || heloNameInput === ""
? undefined
: validateHeloName(heloNameInput);
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
const password = input?.password === "" ? undefined : input?.password;
if (username !== undefined && password === undefined) {
throw new MailConfigError("MAIL_USERNAME_WITHOUT_PASSWORD");
}
if (password !== undefined && username === undefined) {
throw new MailConfigError("MAIL_PASSWORD_WITHOUT_USERNAME");
}
const fromAddress = input?.fromAddress?.trim();
if (fromAddress === undefined || fromAddress === "") {
throw new MailConfigError("MAIL_FROM_ADDRESS_MISSING");
}
if (!isValidMailAddress(fromAddress)) {
throw new MailConfigError("MAIL_FROM_ADDRESS_INVALID");
}
const fromName = input?.fromName?.trim() === "" ? undefined : input?.fromName?.trim();
if (fromName !== undefined && !isValidMailDisplayName(fromName)) {
throw new MailConfigError("MAIL_FROM_NAME_INVALID");
}
return Object.freeze({
fromAddress,
host: validatedHost,
port,
security,
...(heloName === undefined ? {} : { heloName }),
...(fromName === undefined ? {} : { fromName }),
...(username === undefined || password === undefined ? {} : { password, username }),
});
}
/**
* A one-line operator-facing description that is safe to print anywhere — Doctor output, a log line,
* a support transcript.
*
* It names the host, port, security mode, and sender, and states **whether** authentication is
* configured without ever naming the username or password. `describeStorageProvider` sets the same
* precedent.
*/
export function describeMailProvider(config: MailProviderConfig): string {
const sender =
config.fromName === undefined
? config.fromAddress
: `${config.fromName} <${config.fromAddress}>`;
// The HELO name is host infrastructure, not a credential: it is the identity this client announces
// in the clear on every connection, so printing it costs nothing and is exactly what an operator
// needs when a relay has refused the greeting.
const helo = config.heloName === undefined ? "" : ` · HELO ${config.heloName}`;
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
config.username === undefined ? "unauthenticated relay" : "authenticated"
}${helo}`;
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./config.js";
export * from "./message.js";
export * from "./provider.js";
export * from "./smtp-provider.js";
+234
View File
@@ -0,0 +1,234 @@
/**
* The one transactional message shape Ekklesia sends, and the boundary that validates it.
*
* **There is deliberately no `from` field.** The sender is host configuration
* (`MailProviderConfig.fromAddress`), so no caller — not Core, not a future module — can choose who
* a message appears to come from. Letting feature code pick a sender is how a platform acquires an
* impersonation primitive and destroys its own domain reputation at the same time (ADR 0037).
*
* Also deliberately absent: CC, BCC, attachments, custom headers, and templates. Identity
* verification, password reset, and invitations each need exactly one recipient and a short body.
* Every field beyond that is surface a future caller could abuse, so it stays out until something
* real needs it.
*/
export type MailErrorCode =
| "MAIL_MESSAGE_INVALID"
| "MAIL_NOT_CONFIGURED"
| "MAIL_PROVIDER_UNAVAILABLE"
| "MAIL_RECIPIENT_INVALID"
| "MAIL_REJECTED"
| "MAIL_SEND_FAILED";
const mailErrorMessages: Readonly<Record<MailErrorCode, string>> = Object.freeze({
MAIL_MESSAGE_INVALID: "The message is not one this contract accepts.",
MAIL_NOT_CONFIGURED: "This installation has no outbound email provider configured.",
MAIL_PROVIDER_UNAVAILABLE: "The outbound email service could not be reached.",
MAIL_RECIPIENT_INVALID: "The recipient address is not a valid email address.",
MAIL_REJECTED: "The outbound email service refused the message.",
MAIL_SEND_FAILED: "The message could not be sent.",
});
/**
* A sanitized mail failure.
*
* Carries a code and a fixed message and **nothing else** — no SMTP host, no port, no username, no
* password, no server greeting, no auth challenge, no provider response text, and no caught cause.
* SMTP servers routinely echo the envelope and sometimes the credential state back in their reply
* text, so forwarding a provider error verbatim is how a password reaches a log. It follows the same
* discipline as `ModuleStorageError` and `StorageConfigError`.
*/
export class MailError extends Error {
readonly code: MailErrorCode;
constructor(code: MailErrorCode) {
super(mailErrorMessages[code]);
this.name = "MailError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Bounds, chosen to be generous for a transactional message and far too small for a newsletter.
*
* A verification email is a couple of kilobytes. These leave two orders of magnitude of headroom
* while making it structurally impossible to hand the provider a megabyte, or to turn one call into
* a mailing list.
*/
export const MAIL_MAX_RECIPIENTS = 10;
export const MAIL_MAX_SUBJECT_LENGTH = 200;
export const MAIL_MAX_TEXT_BYTES = 64 * 1024;
export const MAIL_MAX_HTML_BYTES = 256 * 1024;
/** RFC 5321 caps a path at 256 octets; the local part and domain limits sit inside that. */
export const MAIL_MAX_ADDRESS_LENGTH = 254;
export const MAIL_MAX_DISPLAY_NAME_LENGTH = 128;
export interface MailMessage {
/** One address, or a small bounded list. */
readonly to: string | readonly string[];
readonly subject: string;
/** At least one of `text` or `html` is required — see `validateMailMessage`. */
readonly text?: string;
readonly html?: string;
/** Where a human reply should go, when that is not the host's unattended sender. */
readonly replyTo?: string;
}
/** The message after validation: recipients normalized to a list, and every field proven safe to
* hand a provider. A provider receives only this, never the caller's raw input. */
export interface ValidatedMailMessage {
readonly to: readonly string[];
readonly subject: string;
readonly text?: string;
readonly html?: string;
readonly replyTo?: string;
}
/**
* Whether a string is free of everything that could break out of the field it is placed in.
*
* A mail header is line-oriented, so a carriage return or line feed in a subject, an address, or a
* display name lets a caller append headers of its own — a `Bcc:` to somewhere else, a replaced
* `From:`, or an entire second message body. This is the classic email header injection, and it is
* why these characters are **refused** rather than stripped: silently rewriting a caller's input
* means the value that was validated is not the value that gets sent, and the two can drift.
*
* Every other C0 control, DEL, and the C1 range go with them. None has a legitimate place in a
* transactional subject line, and several are interpreted by intermediaries.
*/
function hasUnsafeCharacters(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint === undefined) {
return true;
}
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return true;
}
}
return false;
}
/**
* A deliberately conservative address check.
*
* This is not an RFC 5322 parser and does not try to be: the point is to reject what is dangerous or
* obviously wrong before it reaches the provider, and to let the provider be authoritative about
* exotic-but-legal mailboxes. What it does guarantee is that the value is a single, bare
* `local@domain` with no display name, no angle brackets, no comma, no semicolon, and no control
* character — so it cannot smuggle a second recipient or a header break through a field the caller
* believes holds one address.
*/
export function isValidMailAddress(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
// Checked **before** trimming, deliberately. Trimming first would silently strip a trailing
// carriage return and accept the address, which contradicts the rule above: a control character in
// an address is refused, never repaired. Surrounding ordinary whitespace is still tolerated,
// because that is a copy-paste artefact rather than an injection attempt.
if (hasUnsafeCharacters(value)) {
return false;
}
const address = value.trim();
if (address.length === 0 || address.length > MAIL_MAX_ADDRESS_LENGTH) {
return false;
}
// No grouping or list punctuation: one address means one address.
if (/[,;<>"\\()[\]\s]/u.test(address)) {
return false;
}
const at = address.lastIndexOf("@");
if (at <= 0 || at === address.length - 1) {
return false;
}
const local = address.slice(0, at);
const domain = address.slice(at + 1);
if (local.length > 64 || local.includes("@")) {
return false;
}
// A domain of at least two dot-separated labels, each alphanumeric with internal hyphens. This
// rejects a bare hostname, which is legal SMTP but never a real transactional recipient and is a
// common shape for a typo.
return /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/iu.test(
domain,
);
}
/** A display name that can be placed in a `From:` without escaping surprises. Quotes and backslashes
* are refused rather than escaped, for the same reason control characters are. */
export function isValidMailDisplayName(value: unknown): value is string {
return (
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= MAIL_MAX_DISPLAY_NAME_LENGTH &&
!hasUnsafeCharacters(value) &&
!/["\\]/u.test(value)
);
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
/**
* Validates one message and normalizes its recipients.
*
* Recipient problems get their own code because they are the one failure an operator or a user can
* usually act on — a mistyped address — while everything else here is a programming error in the
* calling feature.
*/
export function validateMailMessage(message: MailMessage): ValidatedMailMessage {
const recipients = (Array.isArray(message.to) ? message.to : [message.to]) as readonly unknown[];
if (recipients.length === 0 || recipients.length > MAIL_MAX_RECIPIENTS) {
throw new MailError("MAIL_RECIPIENT_INVALID");
}
const to: string[] = [];
for (const recipient of recipients) {
if (!isValidMailAddress(recipient)) {
throw new MailError("MAIL_RECIPIENT_INVALID");
}
to.push(recipient.trim());
}
if (new Set(to.map((address) => address.toLowerCase())).size !== to.length) {
throw new MailError("MAIL_RECIPIENT_INVALID");
}
const subject = message.subject;
if (
typeof subject !== "string" ||
subject.trim().length === 0 ||
subject.length > MAIL_MAX_SUBJECT_LENGTH ||
hasUnsafeCharacters(subject)
) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
// At least one body. A message with neither is not a message, and silently sending an empty one
// would be worse than refusing it.
const hasText = typeof message.text === "string" && message.text.length > 0;
const hasHtml = typeof message.html === "string" && message.html.length > 0;
if (!hasText && !hasHtml) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
if (hasText && byteLength(message.text as string) > MAIL_MAX_TEXT_BYTES) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
if (hasHtml && byteLength(message.html as string) > MAIL_MAX_HTML_BYTES) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
if (message.replyTo !== undefined && !isValidMailAddress(message.replyTo)) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
return Object.freeze({
to: Object.freeze(to),
subject,
...(hasText ? { text: message.text as string } : {}),
...(hasHtml ? { html: message.html as string } : {}),
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo.trim() }),
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { ValidatedMailMessage } from "./message.js";
/**
* The seam between Ekklesia's mail contract and whatever actually delivers a message.
*
* This is the **portability boundary made executable**, exactly as `StorageProvider` is for object
* storage. Everything above it — the message contract, validation, the sender policy, diagnostics —
* is provider-agnostic and would be identical under a future standalone host. Everything below it is
* one implementation (`createSmtpMailProvider`) that could be replaced by a provider HTTP API
* without any calling feature changing a line.
*
* It speaks only in **already-validated** messages: recipients, subject, and bodies have been
* checked and normalized by `validateMailMessage` before anything reaches here, so a provider never
* re-implements those rules and two providers can never disagree about what a caller may send.
*
* Deliberately absent: any way to ask for the sender, the host, the port, or a credential. A
* provider is given configuration at construction and exposes none of it afterwards.
*/
export interface MailProvider {
/**
* Sends one message. Resolves with a provider message id when the service accepted it.
*
* **This is a single bounded attempt, not a delivery guarantee.** Acceptance means the next hop
* took responsibility, not that anyone received anything — and a failure after the message data
* has been transmitted is genuinely ambiguous, because the server may have accepted it and only
* the acknowledgement was lost. Nothing here retries; see ADR 0037.
*/
send(message: ValidatedMailMessage): Promise<MailSendResult>;
/**
* Opens a connection and, when credentials are configured, authenticates — then closes, **without
* sending anything**. For diagnostics only.
*
* Its result is never persisted and never influences module lifecycle state: a mail service being
* briefly unreachable is an operational condition, not an administrative decision.
*/
verify(): Promise<void>;
/** Releases provider connections. Called once, during API shutdown. */
close(): Promise<void>;
}
export interface MailSendResult {
/**
* The provider's own identifier for the accepted message, when it supplies one.
*
* Safe to log: it identifies a message in the provider's records and contains no recipient, no
* subject, and no body. It is deliberately the *only* thing about a sent message this contract
* hands back, because a verification or reset email's contents are exactly what must not end up
* in an operator's log (ADR 0037).
*/
readonly messageId?: string;
}
+211
View File
@@ -0,0 +1,211 @@
import { createTransport, type Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
import type { MailProviderConfig } from "./config.js";
import { MailError } from "./message.js";
import type { MailProvider, MailSendResult } from "./provider.js";
/**
* Ekklesia's first real `MailProvider`, speaking SMTP.
*
* **It is not named after a service, and it must not become one.** No provider's hostname appears
* anywhere in this file, and a test asserts that. Everything specific to a deployment arrives as
* validated configuration, so the same code addresses a Google Workspace relay, Microsoft 365, a
* transactional provider's SMTP endpoint, a local Postfix, or anything else that speaks the
* protocol (ADR 0037).
*
* Server-only, and structurally so: this module imports Nodemailer and reaches the network, and no
* browser package can import it — a surface cannot import Core at all, which the boundary checker
* enforces.
*/
/**
* How long a single SMTP exchange may take before it is abandoned.
*
* These exist because the object-storage pass learned the lesson the hard way: a network client left
* on its library's defaults will wait indefinitely, and a provider that accepts a connection and
* then goes silent holds the calling request open forever. Nodemailer's own defaults are two
* minutes for a connection and ten for a socket, which is not a deadline anyone would choose for a
* password-reset email a person is waiting on.
*
* `socketTimeout` is the most generous of the three because it also covers transmitting the message
* body, and it is inactivity-based rather than total elapsed time, so a large message is never cut
* off for being large — only for going quiet. A wholly unresponsive server therefore surfaces as a
* structured `MAIL_PROVIDER_UNAVAILABLE` in well under a minute rather than never.
*/
export interface SmtpTransportBounds {
/** Milliseconds allowed to establish the TCP connection. */
readonly connectionTimeoutMs: number;
/** Milliseconds allowed for the server's opening greeting. */
readonly greetingTimeoutMs: number;
/** Milliseconds the socket may sit idle mid-exchange; resets whenever bytes move. */
readonly socketTimeoutMs: number;
}
const DEFAULT_TRANSPORT_BOUNDS: SmtpTransportBounds = Object.freeze({
connectionTimeoutMs: 10_000,
greetingTimeoutMs: 10_000,
socketTimeoutMs: 30_000,
});
export const SMTP_CONNECTION_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.connectionTimeoutMs;
export const SMTP_GREETING_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.greetingTimeoutMs;
export const SMTP_SOCKET_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.socketTimeoutMs;
interface SmtpLikeError {
readonly code?: unknown;
readonly responseCode?: unknown;
readonly rejected?: unknown;
}
function errorCode(error: unknown): string {
const candidate = error as SmtpLikeError | null;
return typeof candidate?.code === "string" ? candidate.code : "";
}
function responseCode(error: unknown): number | undefined {
const candidate = error as SmtpLikeError | null;
return typeof candidate?.responseCode === "number" ? candidate.responseCode : undefined;
}
/**
* Reduces any Nodemailer or socket failure to one of the contract's closed codes.
*
* **Nothing from the caught error survives this function** — not its message, not the server's
* response text, not the greeting, not the auth challenge, and not the envelope. SMTP servers
* routinely echo back what was sent to them, and an authentication failure's response can name the
* account; forwarding any of that verbatim is how a credential or a recipient reaches a log.
*/
export function translateMailError(error: unknown): MailError {
const code = errorCode(error);
const status = responseCode(error);
// A named recipient the server refused: the one failure a human can usually act on, so it keeps
// its own code rather than collapsing into a generic send failure.
if (code === "EENVELOPE") {
const rejected = (error as SmtpLikeError).rejected;
if (Array.isArray(rejected) && rejected.length > 0) {
return new MailError("MAIL_RECIPIENT_INVALID");
}
}
// Connection-level failures, including both timeouts configured above.
if (
code === "ECONNECTION" ||
code === "ESOCKET" ||
code === "ETIMEDOUT" ||
code === "EDNS" ||
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EHOSTUNREACH" ||
code === "ENOTFOUND"
) {
return new MailError("MAIL_PROVIDER_UNAVAILABLE");
}
if (status !== undefined) {
// 4xx is SMTP's "try later" and 5xx its "no". Splitting on that is what lets a caller tell a
// transient outage from a message that will never be accepted.
return status >= 500
? new MailError("MAIL_REJECTED")
: new MailError("MAIL_PROVIDER_UNAVAILABLE");
}
return new MailError("MAIL_SEND_FAILED");
}
function transportOptions(
config: MailProviderConfig,
bounds: SmtpTransportBounds,
): SMTPTransport.Options {
return {
// Bounded on every axis. See the constants above.
connectionTimeout: bounds.connectionTimeoutMs,
// Defence in depth: this platform never sends attachments, so Nodemailer must never be able to
// read a local file or fetch a URL on the strength of something that reached a message body.
disableFileAccess: true,
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
// harder to reason about.
port: config.port,
// `secure` means implicit TLS from the first byte. For STARTTLS it must be false and the upgrade
// demanded separately, below.
secure: config.security === "implicit-tls",
socketTimeout: bounds.socketTimeoutMs,
// The downgrade guard. With `requireTLS`, a STARTTLS transport refuses to continue in the clear
// if the server does not offer the upgrade — so a server that stops advertising it, or an
// attacker who strips it from the EHLO response, produces a failed send rather than a plaintext
// session carrying a password. `ignoreTLS` is the opposite instruction and is set only for the
// mode that has explicitly asked for no TLS at all, which production configuration refuses.
...(config.security === "starttls" ? { requireTLS: true } : {}),
...(config.security === "plaintext" ? { ignoreTLS: true } : {}),
// Certificate verification is left at Nodemailer's default, which verifies. There is
// deliberately no configuration path that could switch it off, and a test asserts this file
// never names the option: a setting that disables verification is one someone eventually
// disables in production.
...(config.username === undefined || config.password === undefined
? {}
: { auth: { pass: config.password, user: config.username } }),
};
}
export function createSmtpMailProvider(
config: MailProviderConfig,
/** Tightened only by the live provider suite, so it can prove a deadline actually fires without
* spending the production budget waiting for it. Hosts always take the defaults. */
bounds: Partial<SmtpTransportBounds> = {},
): MailProvider {
const transporter: Transporter<SMTPTransport.SentMessageInfo> = createTransport(
transportOptions(config, { ...DEFAULT_TRANSPORT_BOUNDS, ...bounds }),
);
// Built once, from host configuration, and applied to every message. A caller cannot reach it and
// there is no per-message override, so no feature can send as anyone else.
const from =
config.fromName === undefined
? { address: config.fromAddress, name: "" }
: { address: config.fromAddress, name: config.fromName };
const provider: MailProvider = {
async close(): Promise<void> {
transporter.close();
},
async send(message): Promise<MailSendResult> {
try {
const accepted = await transporter.sendMail({
from,
subject: message.subject,
to: [...message.to],
...(message.text === undefined ? {} : { text: message.text }),
...(message.html === undefined ? {} : { html: message.html }),
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
});
// Exactly one attempt. Nodemailer does not retry a `sendMail` on its own, and nothing here
// adds one: a failure after the message data has gone out is ambiguous, and retrying an
// ambiguous send is how one password-reset request becomes three emails (ADR 0037).
const messageId = (accepted as { messageId?: unknown }).messageId;
return Object.freeze(typeof messageId === "string" ? { messageId } : {});
} catch (error) {
throw translateMailError(error);
}
},
async verify(): Promise<void> {
try {
await transporter.verify();
} catch (error) {
throw translateMailError(error);
}
},
};
return Object.freeze(provider);
}
+84 -6
View File
@@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue {
readonly path?: string; 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 { export interface ResolvedModuleEntrypoints {
readonly server: string; readonly server: string;
readonly schema?: string; readonly schema?: string;
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
export interface DiscoveredModule { export interface DiscoveredModule {
readonly directory: string; 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 entrypoints: ResolvedModuleEntrypoints;
readonly manifest: ReadonlyModuleManifest; readonly manifest: ReadonlyModuleManifest;
readonly manifestPath: string; readonly manifestPath: string;
@@ -110,6 +126,7 @@ interface ModulePackageData {
} }
interface ModuleCandidate { interface ModuleCandidate {
contributions: readonly ResolvedModuleContribution[];
directory: string; directory: string;
directoryName: string; directoryName: string;
entrypoints: ResolvedModuleEntrypoints; entrypoints: ResolvedModuleEntrypoints;
@@ -257,13 +274,19 @@ function validatePackageData(
return { name, version }; return { name, version };
} }
interface ResolvedModuleArtifacts {
readonly contributions: readonly ResolvedModuleContribution[];
readonly entrypoints: ResolvedModuleEntrypoints;
}
async function resolveEntrypoints( async function resolveEntrypoints(
moduleDirectory: string, moduleDirectory: string,
directoryName: string, directoryName: string,
manifest: ModuleManifest, manifest: ModuleManifest,
issues: ModuleDiscoveryIssue[], issues: ModuleDiscoveryIssue[],
): Promise<ResolvedModuleEntrypoints | undefined> { ): Promise<ResolvedModuleArtifacts | undefined> {
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {}; const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
const resolvedContributions = new Map<string, string>();
const canonicalModuleDirectory = await realpath(moduleDirectory); const canonicalModuleDirectory = await realpath(moduleDirectory);
const { display, ...ordinaryEntrypoints } = manifest.entrypoints; const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints) const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
issuePath: `entrypoints.${entrypointName}`, issuePath: `entrypoints.${entrypointName}`,
relativeEntrypoint, 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 = [ const configuredEntrypoints = [
...ordinaryConfiguredEntrypoints, ...ordinaryConfiguredEntrypoints,
...(display === undefined ...(display === undefined
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
relativeEntrypoint: display.artifact, relativeEntrypoint: display.artifact,
}, },
]), ]),
...configuredContributions,
]; ];
let entrypointCollision = false; let entrypointCollision = false;
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
}); });
continue; continue;
} }
if (entrypointName.startsWith("contribution:")) {
resolvedContributions.set(
entrypointName.slice("contribution:".length),
canonicalEntrypoint,
);
} else {
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint; resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
}
} catch (error) { } catch (error) {
const code = errorCode(error); const code = errorCode(error);
issues.push({ 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 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, server: resolved.server,
...(resolved.schema ? { schema: resolved.schema } : {}), ...(resolved.schema ? { schema: resolved.schema } : {}),
...(resolved.migrations ? { migrations: resolved.migrations } : {}), ...(resolved.migrations ? { migrations: resolved.migrations } : {}),
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
} }
: {}), : {}),
}; };
return { contributions: Object.freeze(contributions), entrypoints };
} }
async function scanModuleDirectory( async function scanModuleDirectory(
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
}); });
} }
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!entrypoints) { if (!artifacts) {
return undefined; return undefined;
} }
return { return {
contributions: artifacts.contributions,
directory: canonicalModuleDirectory, directory: canonicalModuleDirectory,
directoryName, directoryName,
entrypoints, entrypoints: artifacts.entrypoints,
manifest, manifest,
manifestPath: canonicalManifestPath, manifestPath: canonicalManifestPath,
packageName: packageData.name, packageName: packageData.name,
+2
View File
@@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROLLBACK_TIMEOUT" | "MODULE_ROLLBACK_TIMEOUT"
| "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_DUPLICATE" | "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_BODY_MODE_INVALID"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_OVERLAP" | "MODULE_ROUTE_OVERLAP"
@@ -467,6 +468,7 @@ class StagedModuleHttpRoutes {
shapes.add(shapeKey); shapes.add(shapeKey);
local.push( local.push(
Object.freeze({ Object.freeze({
bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler, handler: parsed.data.handler,
maxBodyBytes: parsed.data.maxBodyBytes, maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method, method: parsed.data.method,
+155 -1
View File
@@ -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"; import type { DiscoveredModule } from "./discovery.js";
/** /**
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
readonly permissions: readonly PermissionDeclaration[]; 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?" * 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. * a display merely because a caller appends `/display` to its publication URL.
*/ */
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined; 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 { ): ModuleUiPublicationRegistry {
const byModuleId = new Map<string, PublishableModuleUi>(); const byModuleId = new Map<string, PublishableModuleUi>();
const displaysByModuleId = new Map<string, PublishableModuleDisplay>(); 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) { for (const module of modules) {
const artifactPath = module.entrypoints.ui; const artifactPath = module.entrypoints.ui;
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
const published = Object.freeze([...byModuleId.values()]); const published = Object.freeze([...byModuleId.values()]);
const publishedDisplays = Object.freeze([...displaysByModuleId.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({ 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, list: (): readonly PublishableModuleUi[] => published,
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays, listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => { resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}), : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -0,0 +1,456 @@
import type { ModuleManifest } from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest";
import { createPermissionRegistry } from "../src/authorization/index.js";
import { createDoctorOptions } from "../src/cli/doctor.js";
import {
type DoctorCheck,
type DoctorDependencies,
diagnoseEkklesia,
formatDoctorReport,
} from "../src/diagnostics/index.js";
import type {
DiscoveredModule,
ModuleMigrationAggregate,
ModuleSchemaAggregate,
} from "../src/modules/index.js";
/**
* The bug this suite exists for was operational, not theoretical: `pnpm doctor` built its own
* `DoctorOptions` and simply never included object storage or module secrets, so a correctly
* configured installation was told its storage was "not configured" and every module requiring a
* bucket was reported blocked — while `startApi`, from the identical environment, ran them.
*
* These tests therefore go through `createDoctorOptions`, the exact function the CLI calls, with an
* environment fixture rather than a hand-built options object. A helper-only test would have passed
* happily on the broken code.
*/
const databaseUrl = "postgres://[email protected]:5432/ekklesia";
const accessKey = "AKIAEXAMPLEACCESSID";
const secretKey = "sk-example-secret-material";
const smtpPassword = "sup3r-secret-relay-password";
const identitySecret = "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW";
const moduleSecret = "module-secret-material";
const metadata = Object.freeze({
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
function environment(
overrides: Readonly<Record<string, string | undefined>> = {},
): Readonly<Record<string, string | undefined>> {
return {
DATABASE_URL: databaseUrl,
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
EKKLESIA_IDENTITY_SECRET: identitySecret,
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
NODE_ENV: "production",
...overrides,
};
}
const storageEnvironment = Object.freeze({
S3_ACCESS_KEY: accessKey,
S3_ENDPOINT: "https://storage.example.org",
S3_SECRET_KEY: secretKey,
});
const mailEnvironment = Object.freeze({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HOST: "smtp.example.org",
EKKLESIA_SMTP_PASSWORD: smtpPassword,
EKKLESIA_SMTP_SECURITY: "starttls",
EKKLESIA_SMTP_USERNAME: "[email protected]",
});
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
return {
contributions: [],
directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: {
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: [],
dependencies: {
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 7,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
version: "0.1.0",
...overrides,
} as unknown as ModuleManifest,
manifestPath: `/unpublished/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
const storingModule = discoveredModule("announcements", {
storage: { buckets: [{ id: "announcement-assets" }] },
});
const secretRequiringModule = discoveredModule("announcements", {
configuration: [{ key: "storageCredential", required: true, type: "secret" }],
});
function schemaAggregate(): ModuleSchemaAggregate {
return Object.freeze({
discoveredModules: Object.freeze([]),
drizzleSchema: Object.freeze({}),
modules: Object.freeze([]),
tables: Object.freeze([]),
});
}
function migrationAggregate(): ModuleMigrationAggregate {
return Object.freeze({
discoveredModules: Object.freeze([]),
migrations: Object.freeze([]),
modules: Object.freeze([]),
});
}
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
return {
composePermissions: vi.fn((modules: readonly DiscoveredModule[]) =>
createPermissionRegistry(
modules.flatMap((discovered) => [...discovered.manifest.permissions]),
),
),
createMigrationDatabase: vi.fn(() => {
throw new Error("not used");
}),
discoverMigrations: vi.fn(async () => migrationAggregate()),
discoverModules: vi.fn(async () => Object.freeze([])),
discoverSchemas: vi.fn(async () => schemaAggregate()),
planMigrations: vi.fn(async () => {
throw new Error("not used");
}),
probeMail: vi.fn(async () => undefined),
probeStorage: vi.fn(async () => undefined),
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
runtimeSatisfies: vi.fn(() => true),
...overrides,
};
}
async function diagnose(
env: Readonly<Record<string, string | undefined>>,
services: DoctorDependencies = dependencies(),
): Promise<{ readonly report: Awaited<ReturnType<typeof diagnoseEkklesia>> }> {
return { report: await diagnoseEkklesia(createDoctorOptions(env, metadata), services) };
}
function check(
report: Awaited<ReturnType<typeof diagnoseEkklesia>>,
id: DoctorCheck["id"],
): DoctorCheck {
const found = report.checks.find((candidate) => candidate.id === id);
if (!found) {
throw new Error(`Missing test check ${id}.`);
}
return found;
}
describe("Doctor host-configuration parity — object storage", () => {
it("recognizes configured object storage from the deployment environment", async () => {
const services = dependencies();
const { report } = await diagnose(environment(storageEnvironment), services);
const integration = check(report, "object-storage.integration");
// The regression itself: before this pass the CLI passed no storage at all and this read
// "skipped · Object storage not configured" from exactly this environment.
expect(integration.status).toBe("pass");
expect(integration.summary).toBe("Object storage reachable");
expect(services.probeStorage).toHaveBeenCalledWith({
accessKey,
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://storage.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: true,
region: "us-east-1",
secretKey,
});
});
it("still reports genuinely absent storage as absent", async () => {
const services = dependencies();
const { report } = await diagnose(environment(), services);
const integration = check(report, "object-storage.integration");
expect(integration.status).toBe("skipped");
expect(integration.summary).toBe("Object storage not configured");
expect(services.probeStorage).not.toHaveBeenCalled();
});
it("reports a half-configured provider as a configuration failure, naming no value", async () => {
const services = dependencies();
const { report } = await diagnose(
environment({ S3_ENDPOINT: "https://storage.example.org" }),
services,
);
const integration = check(report, "object-storage.integration");
// `startApi` refuses to start on this input, so the deployment is genuinely not ready — this is
// a failure rather than the warning an unreachable-but-valid provider gets.
expect(integration.status).toBe("failure");
expect(integration.details[0]).toBe(
"The object-storage settings were rejected: STORAGE_ACCESS_KEY_MISSING.",
);
expect(report.result).toBe("not-ready");
// Nothing was connected to: a malformed configuration must not produce network traffic.
expect(services.probeStorage).not.toHaveBeenCalled();
});
it("reports a malformed endpoint safely rather than crashing the command", async () => {
const { report } = await diagnose(
environment({ ...storageEnvironment, S3_ENDPOINT: "not-a-url" }),
);
const integration = check(report, "object-storage.integration");
expect(integration.status).toBe("failure");
expect(integration.details[0]).toBe(
"The object-storage settings were rejected: STORAGE_ENDPOINT_INVALID.",
);
expect(JSON.stringify(integration)).not.toContain("not-a-url");
});
it("refuses a plaintext production endpoint exactly as startup would", async () => {
const { report } = await diagnose(
environment({ ...storageEnvironment, S3_ENDPOINT: "http://storage.example.org" }),
);
expect(check(report, "object-storage.integration").details[0]).toBe(
"The object-storage settings were rejected: STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION.",
);
});
it("applies the same region and bucket-prefix defaults the runtime applies", async () => {
const services = dependencies();
await diagnose(environment(storageEnvironment), services);
expect(services.probeStorage).toHaveBeenCalledWith(
expect.objectContaining({
bucketPrefix: "ekklesia",
forcePathStyle: true,
region: "us-east-1",
}),
);
});
it("defaults the browser delivery origin to the endpoint, and flags a separate one", async () => {
const { report: shared } = await diagnose(environment(storageEnvironment));
expect(check(shared, "object-storage.integration").details).toHaveLength(1);
const { report: separate } = await diagnose(
environment({ ...storageEnvironment, S3_DELIVERY_ENDPOINT: "https://files.example.org" }),
);
const integration = check(separate, "object-storage.integration");
expect(integration.details[0]).toContain("browser delivery https://files.example.org");
expect(integration.details[1]).toContain("was not probed");
});
it("binds a module's declared bucket instead of reporting it blocked", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
});
const { report } = await diagnose(environment(storageEnvironment), services);
// The operator-visible consequence of the bug: this module was reported blocked by a Doctor run
// against the very environment that ran it.
expect(check(report, "object-storage.bindings").status).toBe("pass");
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
});
it("still reports a bucket as unbound when no provider is configured", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
});
const { report } = await diagnose(environment(), services);
expect(check(report, "object-storage.bindings").status).toBe("failure");
expect(check(report, "modules.lifecycle").details).toContain(
"announcements 0.1.0 · blocked · storage_unavailable · announcement-assets",
);
});
it("treats a malformed provider as unusable when deciding module eligibility", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
});
const { report } = await diagnose(
environment({ S3_ENDPOINT: "https://storage.example.org" }),
services,
);
// A provider that cannot be constructed cannot bind a bucket, and saying otherwise would report
// a module as runnable that startup would refuse to start at all.
expect(check(report, "object-storage.bindings").status).toBe("failure");
});
});
describe("Doctor host-configuration parity — module secrets", () => {
it("sees a secret the deployment actually set", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])),
});
const { report } = await diagnose(
environment({
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret,
}),
services,
);
const configuration = check(report, "modules.configuration");
// Same class of bug as storage: the CLI never passed a secret source, so a configured secret
// was always reported missing.
expect(configuration.status).toBe("pass");
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
expect(JSON.stringify(report)).not.toContain(moduleSecret);
});
it("still reports a genuinely unset secret as missing", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])),
});
const { report } = await diagnose(environment(), services);
expect(check(report, "modules.configuration").status).toBe("warning");
expect(check(report, "modules.lifecycle").details).toContain(
"announcements 0.1.0 · blocked · secret_missing · storageCredential",
);
});
});
describe("Doctor host-configuration parity — outbound email regression", () => {
it("leaves an installation with no SMTP host reported as complete", async () => {
const services = dependencies();
const { report } = await diagnose(environment(), services);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("skipped");
expect(mailCheck.summary).toBe("Outbound email not configured");
expect(services.probeMail).not.toHaveBeenCalled();
});
it("leaves a configured relay reported as reachable, without its password", async () => {
const services = dependencies();
const { report } = await diagnose(environment(mailEnvironment), services);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("pass");
expect(services.probeMail).toHaveBeenCalledTimes(1);
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" }),
);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("failure");
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_PORT_INVALID.");
expect(JSON.stringify(mailCheck)).not.toContain("70000");
});
it("still refuses a plaintext production relay", async () => {
const { report } = await diagnose(
environment({ ...mailEnvironment, EKKLESIA_SMTP_SECURITY: "plaintext" }),
);
expect(check(report, "outbound-email.configuration").details[0]).toBe(
"The SMTP settings were rejected: MAIL_SECURITY_INSECURE_IN_PRODUCTION.",
);
});
});
describe("Doctor host-configuration parity — sanitization", () => {
it("prints no secret material anywhere in a fully configured report", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
});
const { report } = await diagnose(
environment({
...mailEnvironment,
...storageEnvironment,
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret,
}),
services,
);
const rendered = formatDoctorReport(report).join("\n");
for (const secret of [accessKey, secretKey, smtpPassword, identitySecret, moduleSecret]) {
expect(rendered).not.toContain(secret);
expect(JSON.stringify(report)).not.toContain(secret);
}
// The infrastructure host names are deliberately still shown: they carry no credential and are
// what an operator needs in order to act on the report.
expect(rendered).toContain("https://storage.example.org");
expect(rendered).toContain("smtp.example.org");
});
it("never serializes the environment object wholesale", async () => {
const services = dependencies();
const { report } = await diagnose(
environment({ ...storageEnvironment, TOTALLY_UNRELATED_SECRET: "unrelated-value" }),
services,
);
expect(JSON.stringify(report)).not.toContain("unrelated-value");
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
});
});
+32
View File
@@ -3,6 +3,7 @@ import {
type DoctorDependencies, type DoctorDependencies,
diagnoseEkklesia, diagnoseEkklesia,
formatDoctorReport, formatDoctorReport,
RENDERED_DOCTOR_CHECK_IDS,
} from "../src/diagnostics/index.js"; } from "../src/diagnostics/index.js";
import type { MigrationDatabase } from "../src/migrations/index.js"; import type { MigrationDatabase } from "../src/migrations/index.js";
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js"; import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
@@ -47,6 +48,9 @@ function noConfigurationDependencies(): DoctorDependencies {
planMigrations: async () => { planMigrations: async () => {
throw new Error("database must not be planned"); throw new Error("database must not be planned");
}, },
probeMail: async () => {
throw new Error("mail must not be probed");
},
probeStorage: async () => { probeStorage: async () => {
throw new Error("storage must not be probed"); throw new Error("storage must not be probed");
}, },
@@ -120,9 +124,37 @@ Object storage
✓ No module requires object storage ✓ No module requires object storage
0 modules declare a logical storage bucket. 0 modules declare a logical storage bucket.
Outbound email
○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS`); Result: READY WITH WARNINGS`);
}); });
it("renders every check the report contains, so none can silently affect the result", async () => {
const report = await diagnoseEkklesia(
{
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
noConfigurationDependencies(),
);
// `outbound-email.configuration` was computed, counted toward the overall result, and never
// printed — so a rejected SMTP setting turned an operator's report NOT READY with nothing on
// screen saying why. Asserting the two sets against each other is what keeps a future check
// from repeating it.
expect([...RENDERED_DOCTOR_CHECK_IDS].sort()).toEqual(
report.checks.map((entry) => entry.id).sort(),
);
expect(new Set(RENDERED_DOCTOR_CHECK_IDS).size).toBe(RENDERED_DOCTOR_CHECK_IDS.length);
});
it("does not depend on NO_COLOR or terminal interactivity", async () => { it("does not depend on NO_COLOR or terminal interactivity", async () => {
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
+189 -5
View File
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
return { return {
contributions: [],
directory: `/unpublished/${id}`, directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` }, entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: { manifest: {
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 6, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -138,6 +139,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
discoverModules: vi.fn(async () => Object.freeze([])), discoverModules: vi.fn(async () => Object.freeze([])),
discoverSchemas: vi.fn(async () => schemaAggregate()), discoverSchemas: vi.fn(async () => schemaAggregate()),
planMigrations: vi.fn(async () => plan()), planMigrations: vi.fn(async () => plan()),
probeMail: vi.fn(async () => undefined),
probeStorage: vi.fn(async () => undefined), probeStorage: vi.fn(async () => undefined),
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })), readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })), readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
@@ -187,6 +189,99 @@ function planEntry(
}); });
} }
describe("Ekklesia Doctor outbound email reporting", () => {
const mailConfiguration = {
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "smtp.example.org",
password: "sup3r-secret-relay-password",
security: "starttls",
username: "[email protected]",
};
it("reports an installation with no SMTP host as complete rather than degraded", async () => {
const services = dependencies();
const report = await diagnoseEkklesia(options(), services);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("skipped");
expect(mailCheck.summary).toBe("Outbound email not configured");
// Nothing sends email yet, so an absent provider must not make a deployment read as broken.
expect(report.result).not.toBe("not-ready");
expect(services.probeMail).not.toHaveBeenCalled();
});
it("reports a reachable provider without ever naming a credential", async () => {
const services = dependencies();
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("pass");
const rendered = mailCheck.details.join(" ");
expect(rendered).toContain("smtp.example.org:587");
expect(rendered).toContain("starttls");
expect(rendered).toContain("authenticated");
expect(rendered).not.toContain("sup3r-secret-relay-password");
expect(rendered).not.toContain("[email protected]");
// Honest about what a connection proves: it did not send anything, and deliverability is not
// something this check can establish.
expect(rendered).toMatch(/did not|without sending/iu);
});
it("warns rather than failing when a configured relay does not answer", async () => {
const services = dependencies({
probeMail: vi.fn(async () => {
throw new Error("connection refused to smtp.example.org with sup3r-secret-relay-password");
}),
});
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
const mailCheck = check(report, "outbound-email.configuration");
// A warning, never a failure: the configuration is valid and nothing depends on mail yet.
expect(mailCheck.status).toBe("warning");
expect(report.result).not.toBe("not-ready");
// The thrown value carried a credential; none of it reaches the report.
expect(mailCheck.details.join(" ")).not.toContain("sup3r-secret-relay-password");
});
it("fails a malformed configuration with a sanitized code and probes nothing", async () => {
const services = dependencies();
const report = await diagnoseEkklesia(
{ ...options(), mailConfiguration: { ...mailConfiguration, port: "not-a-port" } },
services,
);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("failure");
const rendered = mailCheck.details.join(" ");
expect(rendered).toContain("MAIL_PORT_INVALID");
expect(rendered).not.toContain("sup3r-secret-relay-password");
// A configuration this broken is never connected to.
expect(services.probeMail).not.toHaveBeenCalled();
});
it("refuses plaintext SMTP when the deployment validates as production", async () => {
const services = dependencies();
const report = await diagnoseEkklesia(
{
...options(),
databaseEnvironment: "production",
mailConfiguration: { ...mailConfiguration, security: "plaintext" },
},
services,
);
expect(check(report, "outbound-email.configuration").details.join(" ")).toContain(
"MAIL_SECURITY_INSECURE_IN_PRODUCTION",
);
});
});
describe("Ekklesia Doctor diagnostics", () => { describe("Ekklesia Doctor diagnostics", () => {
it("publishes a healthy deterministic static report without database configuration", async () => { it("publishes a healthy deterministic static report without database configuration", async () => {
const people = discoveredModule("people"); const people = discoveredModule("people");
@@ -219,6 +314,7 @@ describe("Ekklesia Doctor diagnostics", () => {
"identity.client-address", "identity.client-address",
"object-storage.integration", "object-storage.integration",
"object-storage.bindings", "object-storage.bindings",
"outbound-email.configuration",
]); ]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]); expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
@@ -1283,7 +1379,7 @@ describe("Ekklesia Doctor — object storage", () => {
}); });
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig }, { ...options(databaseUrl), storageConfiguration: storageConfig },
services, services,
); );
const bindings = check(report, "object-storage.bindings"); const bindings = check(report, "object-storage.bindings");
@@ -1303,7 +1399,7 @@ describe("Ekklesia Doctor — object storage", () => {
}); });
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig }, { ...options(databaseUrl), storageConfiguration: storageConfig },
services, services,
); );
const integration = check(report, "object-storage.integration"); const integration = check(report, "object-storage.integration");
@@ -1325,7 +1421,7 @@ describe("Ekklesia Doctor — object storage", () => {
}); });
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig }, { ...options(databaseUrl), storageConfiguration: storageConfig },
services, services,
); );
const integration = check(report, "object-storage.integration"); const integration = check(report, "object-storage.integration");
@@ -1353,7 +1449,7 @@ describe("Ekklesia Doctor — object storage", () => {
}); });
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ ...options(databaseUrl), storage: storageConfig }, { ...options(databaseUrl), storageConfiguration: storageConfig },
services, services,
); );
@@ -1376,3 +1472,91 @@ describe("Ekklesia Doctor — object storage", () => {
expect(check(report, "object-storage.bindings").status).toBe("skipped"); 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,5 +1,5 @@
{ {
"manifestVersion": 6, "manifestVersion": 7,
"id": "db-facade", "id": "db-facade",
"name": "Database Facade Integration Test Module", "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.", "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.",
@@ -0,0 +1,155 @@
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 where deployment configuration is read, which no runtime assertion can
* make.
*
* The defect this pass fixed was structural: two executables each read their own subset of the same
* environment variables, and the subsets drifted until `pnpm doctor` reported a correctly configured
* installation as having no object storage. Restoring parity once is not enough — the arrangement
* that allowed the drift has to stay gone.
*/
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 what the *code* does, and prose that explains a rule inevitably quotes
* the very token the rule forbids. Trailing comments are deliberately left alone so a URL inside a
* string literal is never truncated.
*/
function withoutComments(text: string): string {
return text
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
}
/**
* Every variable the shared loader *interprets* — parses, defaults, splits, or classifies.
*
* `DATABASE_URL` is deliberately absent: it is passed through verbatim, and the small migration and
* owner-bootstrap commands legitimately read it directly. Interpretation is what drifts.
*/
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",
"EKKLESIA_IDENTITY_SECRET",
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
"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",
"EKKLESIA_SMTP_SECURITY",
"EKKLESIA_SMTP_USERNAME",
"NODE_ENV",
"S3_ACCESS_KEY",
"S3_BUCKET_PREFIX",
"S3_DELIVERY_ENDPOINT",
"S3_ENDPOINT",
"S3_FORCE_PATH_STYLE",
"S3_REGION",
"S3_SECRET_KEY",
]);
describe("host configuration is read in exactly one place", () => {
it("never reads the process environment from the shared loader itself", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "host"))) {
// The loader is reusable Core code: it takes the environment as an argument, exactly as
// `createEnvironmentModuleSecretSource` does, so both executables can hand it theirs.
expect(withoutComments(file.text), file.path).not.toContain("process.env");
}
});
it("interprets each host infrastructure variable in exactly one file", async () => {
const sources = [
...(await readTypeScriptFiles(path.join("core", "src"))),
...(await readTypeScriptFiles(path.join("apps", "api", "src"))),
];
for (const file of sources) {
const code = withoutComments(file.text);
for (const variable of INTERPRETED_VARIABLES) {
// Reading one of these anywhere else means a second, independently drifting interpretation
// of the same deployment — which is exactly the defect this pass fixed. Operator-facing
// message text that *names* a variable is untouched: only an actual read is forbidden.
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env.${variable}`);
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env["${variable}"]`);
}
}
});
it("leaves the two executables reading nothing but their own listener address", async () => {
const doctorCli = withoutComments(
await readFile(path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"), "utf8"),
);
const server = withoutComments(
await readFile(path.join(repositoryRoot, "apps", "api", "src", "server.ts"), "utf8"),
);
for (const source of [doctorCli, server]) {
expect(source).toContain("loadHostConfiguration");
expect(source).not.toContain("DATABASE_URL");
}
// `EKKLESIA_API_HOST`/`EKKLESIA_API_PORT` describe this listener rather than the host's
// infrastructure, so Doctor has no use for them and they stay with the process that binds.
expect(server).toContain("EKKLESIA_API_HOST");
expect(doctorCli).not.toContain("EKKLESIA_API_");
});
it("reads the environment only from executables, never from reusable Core", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src"))) {
if (file.path.includes(`${path.sep}cli${path.sep}`)) {
continue;
}
expect(withoutComments(file.text), file.path).not.toContain("process.env");
}
});
it("gives Doctor the same object-storage and secret inputs the runtime receives", async () => {
const doctorCli = await readFile(
path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"),
"utf8",
);
// The two options whose absence was the bug. A future edit that drops either fails here rather
// than in a support conversation about storage that Doctor says is missing.
expect(doctorCli).toContain("storageConfiguration: host.storage");
expect(doctorCli).toContain("moduleSecrets: host.moduleSecrets");
});
});
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import { createDatabaseTransportPolicy } from "../src/database/index.js";
import { loadHostConfiguration } from "../src/host/index.js";
import { createIdentityConfig } from "../src/identity/index.js";
import { createMailProviderConfig } from "../src/mail/index.js";
import { createStorageProviderConfig } from "../src/storage/index.js";
/**
* A complete, valid production deployment. Every optional service is present, because the bug this
* suite exists for was a *configured* service being reported absent — an empty environment would
* never have shown it.
*/
const productionEnvironment = Object.freeze({
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: "10.40.0.1, 10.40.0.2 ,",
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org, https://display.example.org",
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",
EKKLESIA_SMTP_SECURITY: "starttls",
EKKLESIA_SMTP_USERNAME: "[email protected]",
NODE_ENV: "production",
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
S3_BUCKET_PREFIX: "ekklesia",
S3_DELIVERY_ENDPOINT: "https://files.example.org",
S3_ENDPOINT: "https://storage.example.org",
S3_FORCE_PATH_STYLE: "true",
S3_REGION: "us-east-1",
S3_SECRET_KEY: "sk-example-secret-material",
});
describe("host configuration loading", () => {
it("never reads the process environment, only the object it is given", () => {
// Proved by construction: an empty environment produces an unconfigured deployment even though
// this test process's own `process.env` may carry DATABASE_URL, S3_*, or EKKLESIA_SMTP_*.
const host = loadHostConfiguration({});
expect(host.databaseUrl).toBeUndefined();
expect(host.storage.endpoint).toBeUndefined();
expect(host.mail.host).toBeUndefined();
expect(host.identity.secret).toBeUndefined();
});
it("carries every object-storage field the storage passes established", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.storage).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://files.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: "true",
region: "us-east-1",
secretKey: "sk-example-secret-material",
});
});
it("carries every SMTP field, leaving credentials optional as a pair", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.mail).toEqual({
fromAddress: "[email protected]",
fromName: "Ekklesia",
heloName: "mail.example.org",
host: "smtp.example.org",
password: "sup3r-secret-relay-password",
port: "587",
security: "starttls",
username: "[email protected]",
});
const relayByAddress = loadHostConfiguration({
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
EKKLESIA_SMTP_HOST: "smtp-relay.example.org",
});
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();
});
it("resolves module secrets only under names derived from a module's own declared key", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(host.moduleSecrets.get("announcements", "storageCredential")).toBe(
"module-secret-material",
);
expect(host.moduleSecrets.get("announcements", "unknownKey")).toBeUndefined();
// There is no accessor that takes a raw variable name, so a module can never reach these.
expect(host.moduleSecrets.get("announcements", "DATABASE_URL")).toBeUndefined();
});
it("treats only the exact string development as development", () => {
expect(loadHostConfiguration({ NODE_ENV: "development" }).environment).toBe("development");
for (const value of ["Development", "dev", "test", "", "production", undefined]) {
expect(loadHostConfiguration({ NODE_ENV: value }).environment).toBe("production");
}
});
it("omits client-address settings entirely unless a mode is set", () => {
expect(loadHostConfiguration({}).clientAddress).toBeUndefined();
// Half-configuring proxy mode by setting only the header must not silently enable it.
expect(
loadHostConfiguration({ EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for" }).clientAddress,
).toBeUndefined();
expect(loadHostConfiguration(productionEnvironment).clientAddress).toEqual({
header: undefined,
mode: "trusted-proxy",
trustedProxies: ["10.40.0.1", "10.40.0.2"],
});
});
it("omits database transport keys that are unset rather than passing undefined values", () => {
expect(loadHostConfiguration({}).databaseTransport).toEqual({});
expect(
loadHostConfiguration({
EKKLESIA_DATABASE_CA: "-----BEGIN CERTIFICATE-----",
EKKLESIA_DATABASE_TLS_MODE: "verify-full",
}).databaseTransport,
).toEqual({
certificateAuthority: "-----BEGIN CERTIFICATE-----",
mode: "verify-full",
});
});
it("splits comma-separated lists identically wherever they appear", () => {
const host = loadHostConfiguration({
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: " 10.0.0.1 ,, 10.0.0.2 ",
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: " https://a.example.org ,, https://b.example.org ",
});
expect(host.identity.trustedOrigins).toEqual([
"https://a.example.org",
"https://b.example.org",
]);
expect(host.clientAddress?.trustedProxies).toEqual(["10.0.0.1", "10.0.0.2"]);
// A variable that is set but empty yields an empty list, not `undefined` — the two mean
// different things to the identity validator (`TRUSTED_ORIGINS_EMPTY` vs
// `TRUSTED_ORIGINS_MISSING`), and both executables must see the same one.
expect(
loadHostConfiguration({ EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "" }).identity.trustedOrigins,
).toEqual([]);
expect(loadHostConfiguration({}).identity.trustedOrigins).toBeUndefined();
});
it("passes a malformed value through unchanged rather than repairing or discarding it", () => {
// The loader deliberately does not validate: `startApi` wants a malformed value to abort
// startup and Doctor wants it reported, and neither can happen if the load already threw or
// silently dropped it.
const host = loadHostConfiguration({
EKKLESIA_PUBLIC_SIGNUP_ENABLED: "",
S3_ENDPOINT: "not-a-url",
});
expect(host.identity.publicSignupEnabled).toBe("");
expect(host.storage.endpoint).toBe("not-a-url");
expect(() => createStorageProviderConfig(host.storage)).toThrowError(/absolute http/u);
expect(() => createIdentityConfig(host.identity)).toThrowError();
});
it("is accepted unchanged by every Core validator that consumes it", () => {
const host = loadHostConfiguration(productionEnvironment);
expect(createIdentityConfig(host.identity, { environment: host.environment })).toMatchObject({
baseUrl: "https://ekklesia.example.org",
publicSignupEnabled: false,
});
expect(
createDatabaseTransportPolicy(host.databaseTransport, { environment: host.environment }),
).toBeDefined();
expect(createStorageProviderConfig(host.storage, { environment: host.environment })).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://files.example.org",
endpoint: "https://storage.example.org",
forcePathStyle: true,
region: "us-east-1",
secretKey: "sk-example-secret-material",
});
expect(createMailProviderConfig(host.mail, { environment: host.environment })).toMatchObject({
host: "smtp.example.org",
port: 587,
security: "starttls",
});
});
});
+108
View File
@@ -26,6 +26,9 @@ describe("Core identity configuration", () => {
expect(config).toEqual({ expect(config).toEqual({
baseUrl: validBaseUrl, 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, publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: validTrustedOrigins, 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");
}
});
});
+269
View File
@@ -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("&lt;script&gt;");
});
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" },
]);
});
});
+57 -2
View File
@@ -20,6 +20,7 @@ import { identityAdapterSchema } from "../src/identity/schema.js";
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig { function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
return Object.freeze({ return Object.freeze({
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
dashboardOrigin: "https://identity.example.test",
publicSignupEnabled: false, publicSignupEnabled: false,
secret: "correct-horse-battery-staple-0123456789", secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: Object.freeze(["https://identity.example.test"]), trustedOrigins: Object.freeze(["https://identity.example.test"]),
@@ -109,18 +110,21 @@ describe("Core identity runtime construction boundary", () => {
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema }); 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() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ config: validConfig(), db: fakeDb() }); createIdentityRuntime({ config: validConfig(), db: fakeDb() });
expect(betterAuthMock).toHaveBeenCalledTimes(1); expect(betterAuthMock).toHaveBeenCalledTimes(1);
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>]; 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([ expect(Object.keys(options).sort()).toEqual([
"advanced", "advanced",
"baseURL", "baseURL",
"database", "database",
"emailAndPassword", "emailAndPassword",
"emailVerification",
"logger", "logger",
"onAPIError", "onAPIError",
"rateLimit", "rateLimit",
@@ -133,13 +137,60 @@ describe("Core identity runtime construction boundary", () => {
enabled: true, enabled: true,
maxPasswordLength: 128, maxPasswordLength: 128,
minPasswordLength: 8, 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.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
expect(options.baseURL).toBe(validConfig().baseUrl); expect(options.baseURL).toBe(validConfig().baseUrl);
expect(options.secret).toBe(validConfig().secret); expect(options.secret).toBe(validConfig().secret);
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]); 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", () => { it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); 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() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ createIdentityRuntime({
@@ -266,6 +317,10 @@ describe("Core identity runtime construction boundary", () => {
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>]; const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect(options.rateLimit).toEqual({ expect(options.rateLimit).toEqual({
customRules: { 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-in/email": { max: 1000, window: 1 },
"/sign-up/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");
}
});
});
+208
View File
@@ -0,0 +1,208 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Structural guarantees about outbound email that no runtime assertion can make (ADR 0037).
*
* These read the repository itself, because the properties that matter here are about what code
* *exists* and what depends on what — a mail credential that never reaches a browser bundle, and a
* platform that offers no route anyone could use as an open relay.
*/
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
async function readJson(relativePath: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(path.join(repositoryRoot, relativePath), "utf8")) as Record<
string,
unknown
>;
}
async function readTextFiles(directory: string): Promise<Array<{ path: string; text: string }>> {
const { readdir } = await import("node:fs/promises");
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;
}
describe("outbound email stays server-only", () => {
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 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",
]) {
const parsed = await readJson(manifest);
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 () => {
for (const directory of [
"packages/contracts/src",
"packages/module-sdk/src",
"packages/module-ui-sdk/src",
"packages/module-ui-host/src",
"surfaces/shell/src",
]) {
for (const file of await readTextFiles(directory)) {
expect(file.text, file.path).not.toContain("nodemailer");
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
}
}
});
it("gives module code no way to send email at all", async () => {
// Deliberate scope: Core sends, modules do not (yet). `ModuleRuntimeServices` is the one place a
// module capability would appear, and it is added one service at a time when something real
// needs it — nothing does here (ADR 0037).
const services = await readFile(
path.join(repositoryRoot, "packages/contracts/src/module-database.ts"),
"utf8",
);
expect(services).not.toMatch(/mail|email/iu);
for (const directory of ["packages/module-sdk/src", "packages/module-ui-sdk/src"]) {
for (const file of await readTextFiles(directory)) {
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
}
}
});
it("exposes no HTTP route that could send mail", async () => {
// An authenticated relay endpoint is an open relay with extra steps. Nothing in the transport
// layer may reference the mail provider at all.
for (const file of await readTextFiles("apps/api/src")) {
if (file.path.endsWith("server.ts") || file.path.endsWith("config.ts")) {
// The composition root legitimately constructs and closes the provider.
continue;
}
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
}
for (const file of await readTextFiles("apps/api/src")) {
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*send-email[^"'`]*["'`]/u);
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*\/api\/v1\/mail[^"'`]*["'`]/u);
}
});
it("maps SMTP environment in exactly one place, and never inside the mail provider", async () => {
// The mail provider takes validated configuration and nothing else — it never reads the
// environment, under any name.
for (const file of await readTextFiles("core/src/mail")) {
expect(file.text, file.path).not.toContain("process.env");
expect(file.text, file.path).not.toContain("EKKLESIA_SMTP_");
}
// The variable names live in the shared host-configuration loader, which both `startApi` and
// `pnpm doctor` call with their own environment. Two executables mapping these names
// independently is what let Doctor and the runtime disagree about object storage.
const loader = await readFile(
path.join(repositoryRoot, "core/src/host/configuration.ts"),
"utf8",
);
expect(loader).toContain("EKKLESIA_SMTP_HOST");
expect(loader).toContain("EKKLESIA_SMTP_PASSWORD");
// That the loader itself reads no environment is asserted in
// `host-configuration-boundaries.test.ts`, which strips comments first — this file's plain
// substring match would trip over the loader's own prose about the rule.
});
it("keeps no SMTP password out of the committed environment example", async () => {
const example = await readFile(path.join(repositoryRoot, ".env.example"), "utf8");
// Documented, but never with a value: a bootstrap credential in a committed file is a
// credential in every clone.
expect(example).toContain("EKKLESIA_SMTP_HOST");
expect(example).not.toMatch(/^EKKLESIA_SMTP_PASSWORD=.+$/mu);
expect(example).not.toContain("smtp-relay.gmail.com");
});
it("hardcodes no provider hostname in Core", async () => {
// 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")) {
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");
expect(file.text, file.path).not.toContain("NODE_TLS_REJECT_UNAUTHORIZED");
}
});
});
+437
View File
@@ -0,0 +1,437 @@
import { describe, expect, it } from "vitest";
import {
createMailProviderConfig,
describeMailProvider,
MailConfigError,
} from "../src/mail/config.js";
import {
MAIL_MAX_HTML_BYTES,
MAIL_MAX_RECIPIENTS,
MAIL_MAX_SUBJECT_LENGTH,
MAIL_MAX_TEXT_BYTES,
MailError,
validateMailMessage,
} from "../src/mail/message.js";
/**
* The mail contract's boundary, with no provider and no network.
*
* Most of these are negative: the contract's job is to make an impossible message impossible to
* construct, so what it *refuses* is the interesting part (ADR 0037).
*/
const password = "sup3r-secret-relay-password";
const validConfig = {
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "smtp.example.org",
security: "starttls",
};
describe("host mail configuration", () => {
it("treats an absent host as an installation that sends no email", () => {
// The whole platform must still start. Nothing sends mail yet, so this is a complete deployment.
expect(createMailProviderConfig(undefined)).toBeUndefined();
expect(createMailProviderConfig({})).toBeUndefined();
expect(createMailProviderConfig({ host: " " })).toBeUndefined();
});
it("accepts a relay with no credentials, which is how Google Workspace is usually deployed", () => {
const config = createMailProviderConfig(validConfig);
// Requiring a username and password — as most SMTP examples do — would make the intended
// production arrangement, a relay authorized by source IP, unconfigurable.
expect(config).toEqual({
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "smtp.example.org",
port: 587,
security: "starttls",
});
expect(config?.username).toBeUndefined();
expect(config?.password).toBeUndefined();
});
it("defaults the port per security mode, and defaults the mode to an encrypted one", () => {
expect(createMailProviderConfig({ ...validConfig, security: undefined })?.security).toBe(
"starttls",
);
expect(createMailProviderConfig({ ...validConfig, security: "implicit-tls" })?.port).toBe(465);
expect(createMailProviderConfig({ ...validConfig, security: "starttls" })?.port).toBe(587);
expect(createMailProviderConfig({ ...validConfig, port: "2525" })?.port).toBe(2525);
});
it("refuses half a credential pair in either direction", () => {
expect(() =>
createMailProviderConfig({ ...validConfig, username: "[email protected]" }),
).toThrow(expect.objectContaining({ code: "MAIL_USERNAME_WITHOUT_PASSWORD" }));
expect(() => createMailProviderConfig({ ...validConfig, password })).toThrow(
expect.objectContaining({ code: "MAIL_PASSWORD_WITHOUT_USERNAME" }),
);
// Both together is the authenticated case and is accepted.
expect(
createMailProviderConfig({ ...validConfig, password, username: "[email protected]" })
?.username,
).toBe("[email protected]");
});
it.each([
["a scheme", "smtp://smtp.example.org"],
["a port", "smtp.example.org:587"],
["credentials", "user:[email protected]"],
["a path", "smtp.example.org/relay"],
["a space", "smtp example org"],
["nothing but dots", "..."],
])("refuses an SMTP host containing %s", (_label, host) => {
// A host is a hostname, never a connection string — a connection string is how a password ends
// up in a log.
expect(() => createMailProviderConfig({ ...validConfig, host })).toThrow(
expect.objectContaining({ code: "MAIL_HOST_INVALID" }),
);
});
it.each([
["0"],
["65536"],
["-1"],
["587.5"],
["not-a-port"],
])("refuses the malformed port %s", (port) => {
expect(() => createMailProviderConfig({ ...validConfig, port })).toThrow(
expect.objectContaining({ code: "MAIL_PORT_INVALID" }),
);
});
it("treats an empty port as unset rather than malformed", () => {
expect(createMailProviderConfig({ ...validConfig, port: "" })?.port).toBe(587);
});
it("refuses an unknown security mode rather than guessing one", () => {
for (const security of ["tls", "ssl", "none", "STARTTLS", "true"]) {
expect(() => createMailProviderConfig({ ...validConfig, security }), security).toThrow(
expect.objectContaining({ code: "MAIL_SECURITY_INVALID" }),
);
}
});
it("refuses plaintext SMTP in production but allows it in development", () => {
expect(() =>
createMailProviderConfig(
{ ...validConfig, security: "plaintext" },
{ environment: "production" },
),
).toThrow(expect.objectContaining({ code: "MAIL_SECURITY_INSECURE_IN_PRODUCTION" }));
expect(
createMailProviderConfig(
{ ...validConfig, security: "plaintext" },
{ environment: "development" },
)?.security,
).toBe("plaintext");
});
it("requires a valid sender address and refuses an injectable display name", () => {
expect(() => createMailProviderConfig({ ...validConfig, fromAddress: undefined })).toThrow(
expect.objectContaining({ code: "MAIL_FROM_ADDRESS_MISSING" }),
);
for (const fromAddress of [
"not-an-address",
"a@b",
"a@@b.org",
"a [email protected]",
"<[email protected]>",
]) {
expect(() => createMailProviderConfig({ ...validConfig, fromAddress }), fromAddress).toThrow(
expect.objectContaining({ code: "MAIL_FROM_ADDRESS_INVALID" }),
);
}
for (const fromName of ['Ekklesia"', "Ekklesia\\", "Ekklesia\r\nBcc: [email protected]"]) {
expect(() => createMailProviderConfig({ ...validConfig, fromName })).toThrow(
expect.objectContaining({ code: "MAIL_FROM_NAME_INVALID" }),
);
}
});
it("never puts a credential into a configuration error or a description", () => {
const configured = createMailProviderConfig({
...validConfig,
password,
username: "[email protected]",
});
if (!configured) {
throw new Error("The mail configuration fixture produced no configuration.");
}
const described = describeMailProvider(configured);
// An operator sees where mail goes and whether it authenticates — never with what.
expect(described).toContain("smtp.example.org:587");
expect(described).toContain("starttls");
expect(described).toContain("[email protected]");
expect(described).toContain("authenticated");
expect(described).not.toContain(password);
expect(described).not.toContain("[email protected]");
const unauthenticated = createMailProviderConfig(validConfig);
if (!unauthenticated) {
throw new Error("The mail configuration fixture produced no configuration.");
}
expect(describeMailProvider(unauthenticated)).toContain("unauthenticated relay");
let caught: unknown;
try {
createMailProviderConfig({
...validConfig,
host: "smtp://bad",
password,
username: "[email protected]",
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(MailConfigError);
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain(password);
expect(rendered).not.toContain("smtp://bad");
});
});
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",
text: "Open the link to continue.",
to: "[email protected]",
};
it("accepts one recipient and normalizes it to a list", () => {
expect(validateMailMessage(message)).toEqual({
subject: "Verify your email address",
text: "Open the link to continue.",
to: ["[email protected]"],
});
});
it("has no sender field at all, so no caller can choose one", () => {
const validated = validateMailMessage({ ...message, html: "<p>Hi</p>", replyTo: "[email protected]" });
// The load-bearing structural fact: the sender comes from host configuration and there is
// nowhere in a message to put one.
expect(Object.keys(validated).sort()).toEqual(["html", "replyTo", "subject", "text", "to"]);
expect(validated).not.toHaveProperty("from");
});
it.each([
["a display name", "Person <[email protected]>"],
["a second address", "[email protected], [email protected]"],
["a semicolon list", "[email protected];[email protected]"],
["a line feed", "[email protected]\nBcc: [email protected]"],
["a carriage return", "[email protected]\r"],
["no domain dot", "person@localhost"],
["no local part", "@example.org"],
["nothing", ""],
])("refuses a recipient containing %s", (_label, to) => {
expect(() => validateMailMessage({ ...message, to })).toThrow(
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
);
});
it("bounds the recipient list and refuses duplicates", () => {
const many = Array.from(
{ length: MAIL_MAX_RECIPIENTS + 1 },
(_value, index) => `person${index}@example.org`,
);
expect(() => validateMailMessage({ ...message, to: many })).toThrow(
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
);
expect(() => validateMailMessage({ ...message, to: [] })).toThrow(
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
);
// Case-insensitively: the same mailbox twice would send one person two copies.
expect(() =>
validateMailMessage({ ...message, to: ["[email protected]", "[email protected]"] }),
).toThrow(expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }));
expect(
validateMailMessage({ ...message, to: many.slice(0, MAIL_MAX_RECIPIENTS) }).to,
).toHaveLength(MAIL_MAX_RECIPIENTS);
});
it("refuses a subject carrying a header break or no content", () => {
for (const subject of [
"Reset\r\nBcc: [email protected]",
"Reset\nX-Injected: 1",
" ",
"",
]) {
expect(() => validateMailMessage({ ...message, subject })).toThrow(
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
);
}
});
it("bounds the subject and both bodies", () => {
expect(() =>
validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH + 1) }),
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
expect(() =>
validateMailMessage({ ...message, text: "x".repeat(MAIL_MAX_TEXT_BYTES + 1) }),
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
expect(() =>
validateMailMessage({
html: "x".repeat(MAIL_MAX_HTML_BYTES + 1),
subject: message.subject,
to: message.to,
}),
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
// Exactly at the bound is accepted, so the limit is the limit.
expect(
validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH) }).subject,
).toHaveLength(MAIL_MAX_SUBJECT_LENGTH);
});
it("requires at least one body, and accepts either alone", () => {
expect(() => validateMailMessage({ subject: message.subject, to: message.to })).toThrow(
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
);
expect(() => validateMailMessage({ ...message, text: "" })).toThrow(
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
);
expect(
validateMailMessage({ html: "<p>Hi</p>", subject: message.subject, to: message.to }).html,
).toBe("<p>Hi</p>");
});
it("refuses an injectable reply-to", () => {
expect(() =>
validateMailMessage({
...message,
replyTo: "[email protected]\r\nBcc: [email protected]",
}),
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
expect(validateMailMessage({ ...message, replyTo: "[email protected]" }).replyTo).toBe(
"[email protected]",
);
});
it("never echoes the message into the error", () => {
let caught: unknown;
try {
validateMailMessage({
...message,
subject: `secret-${password}` + "\r\nBcc: [email protected]",
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(MailError);
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain(password);
expect(rendered).not.toContain("[email protected]");
});
});
+531
View File
@@ -0,0 +1,531 @@
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";
import { createMailProviderConfig, type MailProviderConfig } from "../src/mail/config.js";
import { MailError, validateMailMessage } from "../src/mail/message.js";
import type { MailProvider } from "../src/mail/provider.js";
import { createSmtpMailProvider } from "../src/mail/smtp-provider.js";
/**
* The SMTP provider against a **real SMTP server** speaking the real protocol.
*
* The unit suite proves the contract with no provider at all; this proves the things only a server
* can. Every claim here is a property of the agreement between Nodemailer and an SMTP server rather
* than of code in this repository: that the host's sender really is the one on the wire, that a
* refused recipient is distinguishable from an outage, that a silent server eventually gives up
* rather than hanging forever, and that a STARTTLS transport refuses to continue in the clear.
*
* `smtp-server` is used rather than a mock because it is the reference implementation from the same
* project as the client, it runs in-process, and it needs no container — so this adds no CI
* infrastructure and sends no real email. **Nothing here reaches the internet**, and no live
* credential exists anywhere in this suite.
*/
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 {
readonly port: number;
readonly captured: CapturedMessage[];
readonly close: () => Promise<void>;
}
const openHarnesses: Array<() => Promise<void>> = [];
const openProviders: MailProvider[] = [];
afterEach(async () => {
for (const provider of openProviders.splice(0)) {
await provider.close().catch(() => undefined);
}
for (const close of openHarnesses.splice(0)) {
await close().catch(() => undefined);
}
});
interface HarnessOptions {
/** When set, the server demands AUTH and accepts only this exact pair. */
readonly auth?: { readonly username: string; readonly password: string };
/** Rejects exactly these recipients at RCPT TO, as a real relay does for an unknown mailbox. */
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> {
const captured: CapturedMessage[] = [];
const server = new SMTPServer({
authOptional: options.auth === undefined,
// Deliberately no TLS unless a scenario asks: proving the client's downgrade guard needs a
// server that does not offer the upgrade.
hideSTARTTLS: options.offerStartTls !== true,
onAuth(auth, _session, callback) {
if (
options.auth !== undefined &&
auth.username === options.auth.username &&
auth.password === options.auth.password
) {
callback(null, { user: auth.username });
return;
}
callback(new Error("Invalid credentials"));
},
onData(stream, session, callback) {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString("utf8");
});
stream.on("end", () => {
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.
const error = new Error("Mailbox unavailable") as Error & { responseCode?: number };
error.responseCode = 550;
callback(error);
return;
}
callback();
},
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.server.address() as AddressInfo;
const close = async (): Promise<void> => {
await new Promise<void>((resolve) => server.close(() => resolve()));
};
openHarnesses.push(close);
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(
{
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "127.0.0.1",
port,
// The local test server speaks no TLS. Production configuration refuses this mode outright,
// which is exactly why the development environment has to be selected explicitly here.
security: "plaintext",
...overrides,
},
{ environment: "development" },
);
if (!built) {
throw new Error("The SMTP integration configuration is invalid.");
}
return built;
}
function provider(providerConfig: MailProviderConfig, bounds = {}): MailProvider {
const created = createSmtpMailProvider(providerConfig, bounds);
openProviders.push(created);
return created;
}
describe.sequential("live SMTP transport", () => {
it("delivers a transactional message with the host's sender, subject, and both bodies", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
const result = await mail.send(
validateMailMessage({
html: "<p>Open the link to continue.</p>",
subject: "Verify your email address",
text: "Open the link to continue.",
to: "[email protected]",
}),
);
expect(harness.captured).toHaveLength(1);
const message = harness.captured[0];
expect(message?.to).toEqual(["[email protected]"]);
// The envelope sender is the host's configured address — the caller never chose it and has no
// field in which to.
expect(message?.from).toBe("[email protected]");
expect(message?.data).toContain("From: Ekklesia <[email protected]>");
expect(message?.data).toContain("Subject: Verify your email address");
expect(message?.data).toContain("Open the link to continue.");
expect(message?.data).toContain("<p>Open the link to continue.</p>");
// A provider message id is the only thing that comes back, and it names no recipient.
expect(result.messageId).toMatch(/@/u);
expect(result.messageId ?? "").not.toContain("[email protected]");
});
it("sends to a small bounded list, and to exactly the addresses given", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
await mail.send(
validateMailMessage({
subject: "Invitation",
text: "You are invited.",
to: ["[email protected]", "[email protected]"],
}),
);
expect(harness.captured[0]?.to).toEqual(["[email protected]", "[email protected]"]);
// No silent extra recipient: nothing in this platform adds a Bcc.
expect(harness.captured[0]?.data).not.toMatch(/^Bcc:/imu);
});
it("carries a reply-to without letting it become the sender", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
await mail.send(
validateMailMessage({
replyTo: "[email protected]",
subject: "Notice",
text: "Body.",
to: "[email protected]",
}),
);
const data = harness.captured[0]?.data ?? "";
expect(data).toContain("Reply-To: [email protected]");
expect(data).toContain("From: Ekklesia <[email protected]>");
expect(harness.captured[0]?.from).toBe("[email protected]");
});
it("authenticates when the relay demands it, and reports a refusal as rejected", async () => {
const harness = await startSmtpServer({
auth: { password: "relay-password", username: "[email protected]" },
});
const authenticated = provider(
config(harness.port, { password: "relay-password", username: "[email protected]" }),
);
await expect(
authenticated.send(
validateMailMessage({ subject: "Authenticated", text: "Body.", to: "[email protected]" }),
),
).resolves.toMatchObject({});
expect(harness.captured).toHaveLength(1);
const wrong = provider(
config(harness.port, { password: "wrong-password", username: "[email protected]" }),
);
let caught: unknown;
try {
await wrong.send(
validateMailMessage({ subject: "Rejected", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(MailError);
expect((caught as MailError).code).toBe("MAIL_REJECTED");
// The server's response text — which can name the account — never reaches the caller.
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain("wrong-password");
expect(rendered).not.toContain("[email protected]");
expect(rendered).not.toContain("Invalid credentials");
// And nothing was delivered.
expect(harness.captured).toHaveLength(1);
});
it("distinguishes a refused mailbox from an outage", async () => {
const harness = await startSmtpServer({ rejectRecipients: ["[email protected]"] });
const mail = provider(config(harness.port));
let caught: unknown;
try {
await mail.send(
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
// The one failure a human can usually act on keeps its own code.
expect((caught as MailError).code).toBe("MAIL_RECIPIENT_INVALID");
expect(harness.captured).toHaveLength(0);
});
it("reports a refused connection as provider-unavailable rather than hanging", async () => {
// A port nothing listens on: a real connection failure rather than a simulated one.
const mail = provider(config(1));
let caught: unknown;
try {
await mail.send(
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(MailError);
expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE");
});
it("gives up on a server that accepts the connection and then never greets", async () => {
// A refused connection fails fast on its own; a server that *answers* and then stalls is the
// failure with no natural end, and the one that has to be bounded deliberately. This is the
// lesson the object-storage pass learned the hard way.
const sockets = new Set<import("node:net").Socket>();
const blackHole: Server = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve) => blackHole.listen(0, "127.0.0.1", resolve));
const port = (blackHole.address() as AddressInfo).port;
const mail = provider(config(port), {
// The production defaults are deliberately patient; this proves the mechanism, not the number.
connectionTimeoutMs: 300,
greetingTimeoutMs: 300,
socketTimeoutMs: 300,
});
const startedAt = Date.now();
let caught: unknown;
try {
await mail.send(
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
const elapsed = Date.now() - startedAt;
expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE");
// The point of the assertion is that it *ended*. Without a deadline Nodemailer waits minutes,
// and a person waiting on a password-reset email waits with it.
expect(elapsed).toBeLessThan(5_000);
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => blackHole.close(() => resolve()));
});
it("refuses to continue in the clear when STARTTLS was required but is not offered", async () => {
const harness = await startSmtpServer({ offerStartTls: false });
const mail = provider(config(harness.port, { security: "starttls" }));
let caught: unknown;
try {
await mail.send(
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
// The downgrade guard. A server that stops advertising STARTTLS — or an attacker who strips it
// — produces a failed send, never a plaintext session carrying a password.
expect(caught).toBeInstanceOf(MailError);
expect(harness.captured).toHaveLength(0);
});
it("verifies connectivity without sending anything", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
await expect(mail.verify()).resolves.toBeUndefined();
// The assertion Doctor depends on: a diagnostic that emailed somebody every time an operator ran
// it would be its own incident.
expect(harness.captured).toHaveLength(0);
const unreachable = provider(config(1));
await expect(unreachable.verify()).rejects.toMatchObject({
code: "MAIL_PROVIDER_UNAVAILABLE",
});
});
it("sends exactly once, with no retry behind the caller's back", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
await mail.send(
validateMailMessage({ subject: "Once", text: "Body.", to: "[email protected]" }),
);
// One call, one message. Retrying an ambiguous send is how one password-reset request becomes
// three emails, so nothing here retries (ADR 0037).
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 { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -27,6 +27,7 @@ function fakeModule(
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -193,7 +194,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") { function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements", id = "announcements",
) { ) {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", 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", () => { it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", 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", () => { it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", 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", () => { it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
], ],
])("rejects %s", (_label, declaration) => { ])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf", "valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => { ])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"", "",
])("rejects the malformed configuration key %s", (key) => { ])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+177 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> { function manifestFor(id: string): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", 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:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -199,7 +200,7 @@ afterAll(async () => {
* by eligibility resolution. */ * by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule { function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", 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" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+3 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -36,6 +36,7 @@ function fakeModule(
}, },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -65,7 +65,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -28,7 +28,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
}, },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [], permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -57,7 +57,7 @@ function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -861,6 +861,7 @@ describe("module HTTP routes", () => {
expect(runtime.routes).toHaveLength(3); expect(runtime.routes).toHaveLength(3);
for (const route of runtime.routes) { for (const route of runtime.routes) {
expect(Object.keys(route).sort()).toEqual([ expect(Object.keys(route).sort()).toEqual([
"bodyMode",
"handler", "handler",
"maxBodyBytes", "maxBodyBytes",
"method", "method",
+1 -1
View File
@@ -41,7 +41,7 @@ function manifestFor(
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {}, options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+2 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 6, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -29,6 +29,7 @@ function fakeModule(
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
+152 -1
View File
@@ -29,6 +29,15 @@ interface ModuleFixture {
readonly ui?: string; readonly ui?: string;
/** A dedicated full-viewport artifact, independent of the primary UI. */ /** A dedicated full-viewport artifact, independent of the primary UI. */
readonly display?: string; 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> { 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, id: fixture.id,
manifestVersion: 6, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined 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 mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display); 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 /** 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()); 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 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. 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 ### Browser TypeScript stays scoped to UI source
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets `@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
@@ -0,0 +1,207 @@
# 0037: Outbound email is a host-provided transport, and SMTP is its first provider
- Status: Accepted
- Date: 2026-08-24
- Extends: [0011](0011-identity-http-transport-and-public-enrollment.md),
[0029](0029-module-configuration-and-secrets.md),
[0030](0030-host-provided-module-object-storage.md),
[0034](0034-host-owned-browser-authentication.md),
[0035](0035-initial-platform-owner-bootstrap.md)
## Context
Ekklesia can authenticate people and pair devices, but it cannot send a single email. Better Auth
has been configured since [ADR 0011](0011-identity-http-transport-and-public-enrollment.md) with
`emailAndPassword` and nothing else: its `sendResetPassword` and `emailVerification.sendVerificationEmail`
hooks are both unimplemented, so email verification, password recovery, and invitations are all
blocked on the same missing thing.
That missing thing is not "a Google Workspace integration". It is a transport — and the shape of the
transport decides whether the features built on it stay portable. The first real deployment is
expected to relay through Google Workspace, but a church that later moves to Microsoft 365, a
transactional provider, or its own Postfix must not be rewriting feature code to do it.
## Decision
Outbound email is a **host-provided infrastructure service** with a provider seam, exactly as object
storage is under [ADR 0030](0030-host-provided-module-object-storage.md):
```text
trusted server-side feature
↓ "send this transactional message"
MailProvider seam
↓
SMTP → whatever the deployment relays through
```
Feature code owns the recipient, subject, and body. The provider owns the host, port, TLS,
credentials, connection behaviour, and every transport error. **No provider's hostname appears
anywhere in Core**, and a test asserts it.
### The contract is deliberately tiny
One recipient or a small bounded list, a subject, a text body, an HTML body, and an optional
reply-to. There are no attachments, no custom headers, no CC, no BCC, no templates, and no mailing
lists. This is transactional infrastructure: every field beyond that is surface a future caller
could abuse, and it stays out until something real needs it.
At least one of `text` or `html` is required. A message with neither is not a message, and sending
an empty one silently would be worse than refusing it.
### There is no `from` field, anywhere
The sender is host configuration. A message has nowhere to put a sender, so no feature — and no
future module — can choose who mail appears to come from. Letting feature code pick a sender is how
a platform acquires an impersonation primitive and destroys its own domain reputation at the same
time. If several approved sender identities are ever genuinely needed, that is a deliberate design
with its own authorization, not a field.
### Header injection is refused, never repaired
Carriage returns, line feeds, and every other control character are rejected in addresses, display
names, subjects, and reply-to values. A mail header is line-oriented, so a `\r\n` in a subject lets a
caller append headers of its own — a `Bcc:` elsewhere, a replaced `From:`, or a second body.
They are **refused rather than stripped** because silently rewriting input means the value that was
validated is not the value that gets sent. Writing that rule down was not enough: the first
implementation trimmed an address before checking it, which quietly accepted a trailing carriage
return, and a test caught it. The check now runs on the raw value.
### Explicit TLS modes, never a boolean
`implicit-tls` (conventionally 465), `starttls` (587/25), or `plaintext`.
A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear and
hope STARTTLS is offered", and a server that stops advertising it — or an attacker who strips it —
turns that into a plaintext session carrying a password, with nothing in the configuration having
changed. Naming the mode makes the downgrade unreachable by accident: `starttls` sets Nodemailer's
`requireTLS`, so a missing upgrade is a failed send. An omitted mode defaults to `starttls`, never to
the one that sends a password in the clear, and production refuses `plaintext` outright.
Certificate verification is left at the library's verifying default, and there is deliberately **no
configuration path that could switch it off** — a setting that disables verification is one someone
eventually disables in production.
### Credentials are optional as a pair
Most SMTP examples assume a username and password. Google Workspace SMTP Relay is commonly
authorized by **source IP** and has neither, so requiring them would make the intended production
arrangement unconfigurable. Half a pair is always a mistake, though — someone set one variable and
expected authentication — so it fails loudly rather than silently connecting anonymously.
The password is a deployment secret. It is never persisted in PostgreSQL, never routed through
module configuration, never returned by any route, never printed by Doctor, and never placed in an
error. It follows the discipline [ADR 0029](0029-module-configuration-and-secrets.md) established.
### Bounded on every axis, and no retry
`connectionTimeout`, `greetingTimeout`, and `socketTimeout` are all set explicitly. Nodemailer's own
defaults are two minutes and ten minutes, which is not a deadline anyone would choose for a
password-reset email someone is waiting on — and the object-storage pass already learned what an
unbounded network client does to a request under load.
**Nothing retries.** A failure after the message data has gone out is genuinely ambiguous: the
server may have accepted it and only the acknowledgement was lost. Retrying an ambiguous send is how
one password-reset request becomes three emails. A single bounded attempt is the whole behaviour,
and `send` resolving means the next hop took responsibility — not that anyone received anything.
This is deliberately **not** an exactly-once promise. A durable outbox with deliberate retry
semantics can be added later if product requirements justify it; guessing at one now would be
inventing delivery guarantees this transport does not have.
### Errors are structured and say nothing
`MAIL_NOT_CONFIGURED`, `MAIL_RECIPIENT_INVALID`, `MAIL_MESSAGE_INVALID`, `MAIL_PROVIDER_UNAVAILABLE`,
`MAIL_REJECTED`, `MAIL_SEND_FAILED`. A 4xx SMTP reply maps to unavailable and a 5xx to rejected, so a
caller can tell a transient outage from something that will never be accepted, and a refused mailbox
keeps its own code because it is the one failure a human can usually act on.
Nothing from the underlying error survives: not the server's response text, not the greeting, not
the auth challenge, not the envelope. SMTP servers routinely echo back what was sent to them, and an
authentication failure's reply can name the account.
### Email is optional, and the platform still runs without it
An installation with no SMTP host configured is a **complete deployment**. Nothing in the platform
sends email yet, so Doctor reports it as `skipped` rather than a warning, and a future feature that
needs to send discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing. Whether email
becomes *required* for particular identity flows is Pass 2's decision to make when those flows exist.
### Nothing sends at startup, and no route sends at all
Constructing the transport opens no socket. The first connection happens when something actually
sends, so an unreachable relay can never stop the platform from booting, and a temporary SMTP outage
is an operational condition rather than a module lifecycle event.
There is deliberately **no `POST /api/v1/send-email`**, and no HTTP route anywhere touches the mail
provider. An authenticated relay endpoint is an open relay with extra steps; a test asserts that no
route file so much as imports the mail module.
### Core only, for now
`ModuleRuntimeServices` carries `database` and nothing else, with a standing note that services are
added **one at a time, when something real needs one**. No module needs email in this pass —
Announcements migration is out of scope — so modules get none, and a test asserts the module SDKs
have no way to send.
This is not a permanent decision. When a module genuinely needs transactional mail, the module-facing
contract goes in `@ekklesia/contracts` and Core layers a scoped facade over this same
`MailProvider` seam — exactly the path object storage took. The seam is already the right shape for
it: a scoped facade would add the module's identity and leave the sender, the bounds, and the
validation exactly where they are.
## Consequences
### The seam Pass 2 wires into
Better Auth's two hooks take a `{ user, url, token }` and return a promise:
```ts
emailAndPassword: { sendResetPassword: async ({ user, url }) => { /* mail.send(...) */ } }
emailVerification: { sendVerificationEmail: async ({ user, url }) => { /* mail.send(...) */ } }
```
`createIdentityRuntime` will take the provider as an injected option and call `mail.send` from
inside them. `startApi` already constructs the provider and closes it on every shutdown path, so
Pass 2 adds the two functions and the message bodies and nothing else. **The `url` and `token` those
hooks receive are credentials** — the whole point of the email is that only the recipient sees them
— so they must never be logged, and the transport already refuses to log a message body.
### What is safe to log
Send attempted, the provider's message id, and an error category. Never a body, never a subject,
never a recipient beyond what an operator genuinely needs, and never a verification or reset link.
The transport itself logs nothing at all.
### Doctor reports configuration, and probes without sending
`outbound-email.configuration` reports absent, valid-and-reachable, valid-but-unreachable, or
structurally invalid. The probe performs the SMTP handshake and, where credentials are set, an
authentication exchange — and **sends no message**: a diagnostic that emailed somebody every time an
operator ran it would be its own incident.
An unreachable relay is a warning rather than a failure, because nothing depends on mail yet and
some relays decline a connection that sends nothing. The report says so explicitly instead of
claiming a health it cannot prove, and it states that a successful connection does not establish
deliverability — that depends on the relay's authorization policy and the domain's SPF, DKIM, and
DMARC records, none of which is knowable from here.
### Google Workspace is a target, not a dependency
It is the expected first deployment and appears nowhere in Core. Whether a relay needs credentials,
and which source addresses it accepts, is configured in Google Workspace Admin and varies per
deployment — so this repository documents the shape and refuses to claim that a relay works
anonymously from an arbitrary host.
### Ekklesia Lite
Lite is a minimal trusted-network module runtime. It may one day supply this same contract if an
operator configures SMTP, and it must never *require* mail to run modules that do not need it —
which is already true, because a provider is optional and its absence is a complete deployment.
Nothing here is implemented in Lite and no module code branches on which host is running it.
### Deferred
Email verification, password reset, and invitations (Pass 2). Templates, which belong to the
features that render them rather than to a transport. Bulk or marketing mail, mailing lists,
attachments, DKIM key management, domain-verification automation, provider-specific HTTP API
adapters, and a durable outbox with retry workers.
@@ -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).
+8
View File
@@ -45,3 +45,11 @@ ADRs capture durable choices that affect more than one package or phase.
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted - [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted - [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 - [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
+105 -2
View File
@@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
independent. independent.
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime 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 `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 `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. 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 `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 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; 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 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 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 were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
@@ -495,6 +497,30 @@ re-pairing mints a new identity rather than reviving a revoked one. Ekklesia Lit
runtime primitives and deliberately no human authentication, permission enforcement, or managed runtime primitives and deliberately no human authentication, permission enforcement, or managed
device identity, so pairing is a full-Ekklesia concern only. device identity, so pairing is a full-Ekklesia concern only.
Phase 7C pass 1 adds outbound email as a host-provided infrastructure service
([ADR 0037](decisions/0037-host-provided-outbound-email.md)), with the same shape object storage
uses: a narrow provider-neutral contract, a `MailProvider` seam, and one concrete SMTP
implementation. Feature code owns the recipient, subject, and body; the provider owns the host,
port, TLS, credentials, and transport errors, and no provider hostname appears in Core. The sender
is host configuration and a message has no `from` field, so nothing above the seam can choose an
identity to send as. Security modes are explicit rather than a boolean plus a port, `starttls`
requires the upgrade, production refuses plaintext, and every timeout is bounded. There is no HTTP
route that sends and no module-facing mail service yet — services are added one at a time when
something real needs one, and nothing does. Email is optional: an installation without SMTP is a
complete deployment, and Better Auth's verification and reset hooks remain unimplemented until
Pass 2.
Phase 7C pass 1B makes host infrastructure configuration a single shared mapping. Every executable —
`apps/api`, `pnpm doctor`, and the migration and owner-bootstrap commands — turns its environment
into typed inputs through one `loadHostConfiguration(env)` in Core, which takes the environment **as
an argument** and never reads `process.env` itself, exactly as the module secret source does.
Validation stays with the existing per-domain validators, so each caller keeps its own failure
behaviour: `startApi` refuses to start on a rejected value, while Doctor reports it as a sanitized
code. This replaced two executables that each read their own subset of the same variables and had
drifted — Doctor never read `S3_*` or module secrets at all, so it reported a correctly configured
installation as having neither, and reported modules blocked that the runtime was running. A
diagnostic that disagrees with the runtime it diagnoses is worse than no diagnostic.
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`, the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
`presentation.kind`, or host-identity branch. The canonical builder reads only `presentation.kind`, or host-identity branch. The canonical builder reads only
@@ -549,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, 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, 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. 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.
+55 -6
View File
@@ -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 | | `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 | | `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 | | `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 | | `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 | | `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 - 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 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 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 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 (including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package, 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 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 ([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. 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 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 `@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)). 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 - `@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 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 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 application shell and all platform policy, and module UI reaches the host only through
`@ekklesia/module-ui-sdk`. `@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 (`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The 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 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. 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 - 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 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. module still owns that renderer.
- Separate surfaces do not import each other's internals. - Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or - 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, direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
columns, relations, schema entrypoint, or migration internals. 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 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 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 every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
+76 -15
View File
@@ -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 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 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, 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 password, URL, or stack reaches browser output or logging. The form offers **no signup** control,
or password-reset link because neither is a supported production flow. 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, 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. 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 have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md). [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 registration policy
**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other **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 ## Request body limits
Every public body-consuming route has an explicit, platform-enforced byte ceiling Every public body-consuming route has an explicit, platform-enforced byte ceiling, enforced by the
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified platform's own limiter (`apps/api/src/body-limits.ts`):
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):
| Surface | Limit | | Surface | Limit |
| --- | --- | | --- | --- |
| `/api/auth/*` (every Better Auth route) | 8 KiB | | `/api/auth/*` (every Better Auth route) | 8 KiB |
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 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 — 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 `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 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 `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 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 authorization work runs. In `streamed` mode no body byte is read until the handler reads it, and the
contract itself, and [Module development](../module-development/README.md#http-routes) for the handler runs only after authorization succeeds, so an unauthorized caller can never push an upload
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module through the platform at all.
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
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 ## Node request and header timeouts
@@ -863,6 +908,20 @@ instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "de
`SameSite=Strict`, a display must be served from the same origin as the API — which the platform's `SameSite=Strict`, a display must be served from the same origin as the API — which the platform's
own `/display/<module-id>` route is. own `/display/<module-id>` route is.
## Outbound email
There is **no HTTP API for it**, deliberately
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). Email is a runtime
capability trusted server-side features call, not a resource the platform exposes: an authenticated
send endpoint is an open relay with extra steps, so no route exists and no route file imports the
mail module. A test asserts both.
Host configuration is `EKKLESIA_SMTP_HOST`, `EKKLESIA_SMTP_PORT`, `EKKLESIA_SMTP_SECURITY`,
`EKKLESIA_SMTP_USERNAME`/`EKKLESIA_SMTP_PASSWORD` (optional as a pair), `EKKLESIA_SMTP_FROM_ADDRESS`,
and `EKKLESIA_SMTP_FROM_NAME`. Absent `EKKLESIA_SMTP_HOST` means this installation sends no email,
which is a complete deployment. None of these values ever reaches a module, a browser bundle, a
diagnostic, or an API response. See [Outbound email](email.md).
## Module HTTP routes ## Module HTTP routes
An installed module's routes (declared via `http.route(...)` in its `register` hook — see An installed module's routes (declared via `http.route(...)` in its `register` hook — see
@@ -1173,7 +1232,7 @@ its role-specific URL only from the protected descriptor below.
## Module display host ## 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)): ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
```json ```json
@@ -1347,9 +1406,11 @@ rotation and client-certificate authentication.
The same policy is resolved identically by every executable that opens a connection — `apps/api`, The same policy is resolved identically by every executable that opens a connection — `apps/api`,
`pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and `pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
`pnpm doctor`'s own connection — through one shared environment reader `pnpm doctor`'s own connection — because they all map the environment through the one shared host
(`core/src/cli/database-transport-environment.ts`), so `process.env` stays out of Core's reusable configuration loader (`core/src/host/configuration.ts`, reached by the small migration commands via
constructors. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth `core/src/cli/database-transport-environment.ts`). The loader takes the environment **as an
argument**, so `process.env` stays out of Core's reusable constructors and only executables read
it. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth
instance, module runtime, or listener exists. `pnpm doctor` reports it as `database.transport` in instance, module runtime, or listener exists. `pnpm doctor` reports it as `database.transport` in
its PostgreSQL section: a verified production policy passes, and an unsafe or invalid one is a its PostgreSQL section: a verified production policy passes, and an unsafe or invalid one is a
blocking `NOT READY` failure — never a warning — naming only the effective mode, never the URL, blocking `NOT READY` failure — never a warning — naming only the effective mode, never the URL,

Some files were not shown because too many files have changed in this diff Show More