69 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
The_River ba32499456 Merge pull request 'feat: give display devices their own paired identity' (#35) from phase-7b/device-pairing into main
CI / verify (push) Successful in 40s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m23s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/35
2026-08-24 05:28:41 +00:00
ekklesiaandClaude Opus 5 80189c8ae2 feat: give display devices their own paired identity
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s
ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page.
Together, the only way to light a television in a foyer was to sign a
human being into it and leave that session running on a screen anyone can
walk up to: a person's credentials on a wall, a person's session as the
thing signage depends on, and the display going dark when they are
revoked or their session simply expires.

A screen is not a person. Ekklesia now recognizes three principal classes
— anonymous, authenticated human, and managed device — and a device is a
first-class principal with its own credential and its own explicit
permissions. It has no email, no password, and no row in the identity
tables, and it satisfies the `authenticated` baseline never, so every
route that settles for "somebody is signed in" stays closed to it.

Device permissions live in their own table because
ekklesia_core_grant.user_id is a non-null foreign key into the user table
and making it polymorphic would rewrite the platform's central
authorization record to serve one narrow case. That is a schema decision,
not a second decision point: the evaluator gained one branch, so a device
is allowed or denied by the same function, with the same deny-by-default,
as anyone else — with no owner path it could reach and no baseline that
stands in for a grant.

The six-digit code is not the credential. It is printed across a screen in
a public hallway, so it is treated as public: it identifies a request to
an administrator and is worth nothing alone. Collecting the credential
also requires a 256-bit claim secret held only in the requesting browser's
HttpOnly cookie, and approving requires the restricted core.devices.manage
permission. Without that split, reading a code off a wall would hand over
the screen's identity.

The permission a pairing asks for is read from the module's validated
display declaration, never from the request, so a browser cannot ask to be
granted core.devices.manage or another module's permission. Approval
grants exactly that one permission.

Credentials are stored only as SHA-256; the raw value exists in the
Set-Cookie that issues it and the device's own cookie jar, and is never
logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so
the module display code running in that same page cannot read it.
Revocation is a server-side column checked on every principal resolution,
and re-pairing mints a new identity so an old credential can never come
back. State transitions are conditional updates, so one code cannot be
approved twice or claimed into two credentials under a race.

/api/v1/principal still reports a device as anonymous: it is what the
Dashboard uses to decide whether a person is signed in. Displays read
their viewer from the descriptor instead, which already performed the
authorization — so displays are descriptor-first while the Dashboard stays
principal-first, and neither infers an identity by elimination.

Proven end to end against real PostgreSQL and real HTTP: an unpaired
screen shows a code, an owner approves it, the device opens the display
descriptor and a realtime stream, and revocation closes both while the
device still holds its cookie. An already-open SSE stream is bounded by
realtime's existing five-minute reconnect rather than closed instantly,
and that bound is documented rather than called immediate.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:41:03 -05:00
The_River 3ce4fe03fe Merge pull request 'feat: create the initial platform owner from an operator command' (#34) from phase-7b/initial-owner-bootstrap into main
CI / verify (push) Successful in 41s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m18s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/34
2026-08-24 03:44:19 +00:00
ekklesiaandClaude Opus 5 c8fa13dd6a feat: create the initial platform owner from an operator command
CI / verify (pull_request) Successful in 41s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m18s
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.

    pnpm identity:create-initial-owner -- --email [email protected]

creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.

The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.

The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.

An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.

Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.

The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.

Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 22:32:53 -05:00
The_River c131543abf Merge pull request 'feat: add browser auth shell' (#33) from phase-7b/browser-auth-shell into main
CI / verify (push) Successful in 38s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m8s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/33
2026-08-24 03:06:01 +00:00
ekklesia a8c7c50b4d feat: add browser auth shell
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s
2026-08-23 22:01:26 -05:00
The_River 91aa9c2930 Merge pull request 'feat: add display kiosk host' (#32) from phase-7a/display-kiosk-host into main
CI / verify (push) Successful in 39s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m2s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/32
2026-08-24 01:54:10 +00:00
ekklesia 2e0fe059c3 feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
2026-08-23 20:50:56 -05:00
The_River be5ed7853d Merge pull request 'feat: add native module realtime transport' (#31) from phase-7a/native-realtime-transport into main
CI / verify (push) Successful in 37s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 1m0s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/31
2026-08-23 23:49:48 +00:00
ekklesia fc6c8045c4 feat: add native module realtime transport
CI / verify (pull_request) Successful in 37s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m7s
2026-08-23 18:47:18 -05:00
The_River fcc531a39c Merge pull request 'feat: deliver module objects directly to browsers through opaque read URLs' (#30) from phase-7a/object-delivery into main
CI / verify (push) Successful in 36s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m4s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/30
2026-08-23 22:17:07 +00:00
ekklesiaandClaude Opus 5 7ff063b87a feat: deliver module objects directly to browsers through opaque read URLs
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 56s
ADR 0030 left exactly one way to get an image to a browser: read the
object into the module's process and write it back out of a module HTTP
route. Every byte of every asset travelled storage -> Ekklesia -> browser.
For the slideshow this platform exists to replace that means Ekklesia in
the path of every image on every display, permanently, for bytes that
never change — and it would make Ekklesia responsible for re-implementing
byte ranges, without which video seeking cannot work at all.

A module can now ask a logical bucket for a temporary browser URL:

    const { url, expiresAt } = await assets.createReadUrl("slides/welcome.png");

The contract names intent, not mechanism. There is no signing, expiry
format, endpoint, or bucket in it — a host with no S3 anywhere could
satisfy the same method, which is what keeps a future single-module
standalone host able to implement it. The URL is opaque to module code and
a bearer capability to whoever holds it, so a module authorizes the
request in its own route before asking: storage bounds reach, never
identity. Lifetimes run one minute to six hours, fifteen by default, and
out-of-range values are refused rather than clamped, so "expires forever"
is unreachable. Logical keys stay canonical state; URLs are generated on
demand and never persisted or logged.

The provider endpoint and the browser delivery origin are separated
because measurement showed they cannot be collapsed: an S3-compatible
signature covers the request Host, so a URL signed for one origin and
rewritten to another is rejected. The browser-facing origin is therefore a
signing input (S3_DELIVERY_ENDPOINT), defaulting to S3_ENDPOINT — so the
standard deployment configures nothing and delivery just works. The scheme
is not signed, so a reverse proxy may terminate TLS provided it passes the
original Host through; that is pinned by a test rather than described and
hoped for.

Read URLs are stable within a window of one third of their lifetime.
Signing is a pure function of the clock, so naive re-signing yields a new
URL per second — a guaranteed browser cache miss, and the reference client
keys its <img> by URL, so an unchanged image would be torn down and
re-downloaded continuously. Quantizing the signing timestamp makes
repeated calls byte-identical while leaving at least two-thirds of the
lifetime; Cache-Control is set at signing time to the guaranteed
remainder, because the pinned SeaweedFS image sends none of its own.

Verified against live SeaweedFS: byte ranges answered directly with 206
and a correct Content-Range, so no range proxy is needed in Core; signing
is local (200 URLs in 66ms), so no batch API is needed; unsigned requests
for the same object are refused, so delivery never depends on a public
bucket; tampered signatures, swapped keys, and swapped buckets all
rejected. Signed write and delete URLs are deliberately not built.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 17:12:07 -05:00
The_River 9a3b8dab8f Merge pull request 'feat: add host-provided module object storage' (#29) from phase-7a/module-storage into main
CI / verify (push) Successful in 37s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 59s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/29
2026-08-23 21:40:12 +00:00
ekklesiaandClaude Opus 5 af4a8009fb fix: bound provider exchanges and stop the storage suite starving its server
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 51s
The live storage suite failed in CI with three failures and a 30s runner
timeout while passing locally in under three seconds. Two independent
defects produced that, one of them a real runtime defect.

Provider calls had no deadline. `S3Client` was built with no
`requestHandler` and no `maxAttempts`, and the SDK's `connectionTimeout`
and `socketTimeout` both default to 0 — disabled. A provider that accepts
a connection and then goes silent therefore held the caller open forever,
bounded in the suite only by vitest and in production by nothing at all.
ADR 0030 promises such a failure reaches a module as the transient
`STORAGE_PROVIDER_UNAVAILABLE` it retries through; `translateStorageError`
already mapped the SDK's `TimeoutError` onto that code, but the branch was
unreachable because nothing ever timed out. The bound is on socket
idleness rather than total elapsed time so a module streaming a large
object is never cut off for being big, only for going quiet.

The suite provisioned a fresh bucket per test. Every S3 bucket becomes its
own SeaweedFS collection claiming a block of volumes from a budget the
server derives from free disk, and `mini` auto-configures a
production-shaped ~1GB volume — so the ceiling is small and varies with
the runner. Twelve buckets per run exhausted it, and an exhausted budget
is not a failed write: the master retries allocation internally and never
replies, which is the hang CI saw. Tests now share one bucket and isolate
by key namespace, which exercises prefix scoping at the same time;
bucket identity is provisioned only where it is the thing under test.
Pinning a small volume size makes the budget depend far less sharply on
the host's disk.

Verified by starving a server on purpose: the same exhaustion that used to
hang until the runner killed it now fails in ~15s with a structured error
and no runner timeout, so the failure is bounded rather than hidden. Ten
consecutive runs pass in ~2.1s each, using 28 volumes per run against a
5212 budget where the old suite used 84 against 161.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 14:54:58 -05:00
ekklesiaandClaude Opus 5 b9ca0d560d feat: add host-provided module object storage
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s
`manifest.storage.buckets` has existed since manifest version 1 and meant
nothing — discovery validated it and no code read it. This turns it into a
real capability, which is what stands between Announcements and the module
system: that product currently fetches a hardcoded LAN S3 endpoint, parses
ListObjectsV2 XML by hand, and hands the browser direct object URLs.

- A module declares a logical bucket and receives a scoped facade at
  `context.storage` — list, head, get, put, delete. The contract names no
  provider: no endpoint, region, credential, or physical bucket is reachable
  from it, which is what lets the same module run under a future standalone
  host over its own or a shared S3-compatible service.
- Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the
  deployment target (ADR 0004), never an API name. Probing the pinned image
  found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's
  `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would
  have failed every second boot. A live test pins it.
- Logical → physical mapping is deterministic and collision-safe: an
  installation prefix, the module and bucket ids joined by `--` (which cannot
  occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide),
  and a digest over the full untruncated identity. Two modules declaring
  `assets` always get separate namespaces; neither can see the physical name.
- Declaring a bucket is now a requirement: with no provider configured the
  module is `blocked · storage_unavailable` through ADR 0028's existing
  resolver, propagating to required dependents. An installation with no object
  storage stays a complete deployment.
- Binding availability is not provider health. Eligibility asks whether
  storage is configured, never whether it answered; bucket creation is lazy on
  first use so a storage outage cannot stop the platform booting; a running
  module whose store blinks stays running and retries. Nothing deletes a
  bucket.
- Credentials are deployment-supplied, never persisted, never routed through
  module configuration, and never visible to modules, module UI, diagnostics,
  or API responses. Provider errors translate to a closed code set carrying no
  endpoint, bucket, request id, or XML.
- Retires ADR 0029's transitional `bucket` configuration key. No manifest
  version change. The template declares no bucket, so it still runs without S3.

See ADR 0030.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 08:45:53 -05:00
The_River 527239f6de Merge pull request 'feat: add the module configuration and secrets contract' (#28) from phase-7a/module-configuration into main
CI / verify (push) Successful in 36s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 46s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/28
2026-08-23 02:51:36 +00:00
ekklesiaandClaude Opus 5 baa689b2a7 feat: add the module configuration and secrets contract
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
Modules had no way to be told anything. Announcements — the first real
product Ekklesia will absorb — has zero environment variables and compiles
a LAN IP address into its source, so this is the contract standing between
it and the module system.

- A module declares what it needs in its manifest, using six bounded types
  (string, integer, boolean, enum, url, secret) with no nesting and no
  regular expressions. Static declaration is what lets the platform validate
  configuration *before* deciding whether a module may start: an enabled
  module missing a required value is reported `blocked ·
  configuration_missing · <key>` rather than taking startup down from inside
  its own `start` hook, and Doctor reports the same while still importing no
  module code. That forces `manifestVersion: 5`, a hard cutover.
- Ordinary values persist in one Core table (migration 0005), one row per
  (module_id, key), stored as text until validated against the declaring
  module's current manifest. Nothing is coerced; an invalid stored value
  blocks the module rather than falling back to the default. A key the
  manifest no longer declares is retained and ignored.
- Secrets are never persisted. There is no encryption at rest in this
  repository, so a `secret` key is deployment-supplied and mapped in by the
  host under a name the module never sees. A secret declaration has no
  `default` field, the write API refuses secret material, and the HTTP view
  has no `value` field for one — absent by construction, not filtered.
- Modules receive a frozen `context.config` on the registration, runtime,
  and HTTP handler contexts: the same object identity, per module, beside
  `services` rather than inside it (services is deliberately one shared
  instance). No environment access, no database reference, no way to reach
  another module's values.
- Configuration decides eligibility through ADR 0028's existing resolver:
  three new blocked reasons, each with a distinct operator fix, propagating
  to required dependents through the same mechanism as a disabled module.
- Changes take effect at the next restart; both resources report
  restartRequired. New restricted permission
  `core.modules.configuration.manage`, separate from lifecycle, at the
  sibling prefix `/api/v1/module-configuration`.
- Migration participation stays independent of configuration validity.
  Endpoints and credentials for shared infrastructure stay platform
  concerns; a module declares which bucket it needs, not an S3 endpoint.

See ADR 0029.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 21:44:43 -05:00
The_River fdaad58848 Merge pull request 'feat: add the minimal operational module lifecycle' (#27) from phase-7a/module-lifecycle into main
CI / verify (push) Successful in 35s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 45s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/27
2026-08-23 02:02:13 +00:00
ekklesiaandClaude Opus 5 7729c99e24 feat: add the minimal operational module lifecycle
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s
Ekklesia had no notion of a module being on or off: the accepted module set
was exactly "whatever discovery found on disk", and `ModuleLifecycleStage`
named nine states nothing produced or consumed. This replaces that vocabulary
with two axes that real code produces — persistent, administrator-owned
configuration (`enabled`/`disabled`) and per-boot runtime observation
(`running`/`disabled`/`blocked`) — and wires them through startup.

- One Core table (`ekklesia_core_module_state`, migration 0004) holding only
  `module_id` and `enabled`. A module with no record is enabled by default, so
  deploying a module is still sufficient to run it and discovery never needs an
  initialization write. A record for an undeployed module is retained and
  ignored, so redeploying restores the decision.
- `resolveModuleEligibility` is pure: a disabled module transitively blocks
  every module that *requires* it (module and capability edges alike) with a
  structured reason naming the provider; optional dependencies never block; and
  a missing, incompatible, or cyclic dependency stays discovery's concern.
- `startApi` starts only the eligible set and builds the UI publication
  registry from that same list, so routes, artifacts, browser inventory, and
  Dashboard navigation all follow one authoritative set. A disabled module is
  never imported, so there is no per-request enablement check anywhere.
- Startup stays all-or-nothing, deliberately; there is consequently no `failed`
  runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band
  recovery path for a module that breaks startup.
- Changes take effect at the next start, and the read model reports
  configuration, runtime, and `restartRequired` separately rather than claiming
  a module stopped when it has not.
- Migration participation and permission composition still span the deployed
  and discovered inventories respectively, unchanged.
- New restricted platform permission `core.modules.lifecycle.manage` gates both
  reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`)
  that cannot collide with the module route namespace.
- `pnpm doctor` gains a `modules.lifecycle` check so "why is this module
  unavailable?" is answerable without reading logs.

See ADR 0028.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 14:45:34 -05:00
The_River db67cf7700 Merge pull request 'feat: add Dashboard navigation as presentation configuration' (#26) from phase-6d/dashboard-navigation into main
CI / verify (push) Successful in 34s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/26
2026-08-22 17:56:43 +00:00
ekklesiaandClaude Opus 5 bd34c9c714 feat: add Dashboard navigation as presentation configuration
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 44s
Phase 6D pass 1. The shell's navigation was a flat list of module ids, which
is not a usable experience — and the obvious fix carries an obvious trap.
Letting a module declare its folder, or modelling "Media" as something
Livestream and Video belong to, turns a rendering preference into
architecture: a folder becomes an owner, membership starts to look like a
dependency, and the flat module graph acquires a hierarchy nobody decided on.

So the rule comes first: a Dashboard group is a label on a rendering bucket.
No manifest, no version, no artifact, no API, no lifecycle; nothing depends on
it. Livestream depends on Video because it calls Video's functionality, and
that orders startup. Livestream is shown under Media, and that orders nothing.
No parent module, child module, owning module, or group-derived dependency
appears anywhere in the contracts.

No manifest change and no version 5. `manifest.name` has been required since
version 1 and already is the module's product identity, so the shell renders
"Livestream" instead of `livestream` with nothing added. `suggestedGroup` is
deliberately deferred rather than shipped: it would force a hard
manifest-version cutover for a hint, and its only correct semantics needs
initialization logic that risks a suggestion overwriting an administrator's
decision. Defaulting and ownership are different things.

Two Core tables (migration 0003) hold groups and placements — server-side,
because a church configures its Dashboard rather than one browser;
platform-global, because no organization model exists to scope them to.
`group_id` is `on delete set null`, so deleting a group ungroups its modules
and a dangling reference is impossible. `module_id` carries no foreign key,
because modules are packages rather than rows, so a placement for an
uninstalled module is retained and restores its position on reinstall.

`resolveDashboardNavigation` is a pure function of configuration plus the
mountable set: an unplaced module is ungrouped and visible with no
initialization write, an absent module renders nothing, empty groups are kept,
and ordering is total with the module id as final tie-break.

`GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of
the host-neutral module UI inventory rather than merged into it — each generic
descriptor is embedded untouched, so a standalone host never inherits Dashboard
semantics. Reading needs the permission the inventory already needs; organizing
needs the new platform permission `core.dashboard.navigation.manage` through a
narrow, idempotent, closed-validation mutation contract. No editor UI.

The flatness rule is executable: a test discovers real fixtures with a genuine
dependency, arranges them four ways including both in one folder and the
dependency inverted in presentation, and asserts discovery order, dependency
declarations, and the module set stay byte-identical. The end-to-end test
organizes a real installation through the real API, mounts a module from inside
a group, moves it, and asserts the navigation entry is byte-identical across the
move and still mounts the same artifact.

The shell's client for the generic inventory became dead code and was removed
rather than kept speculatively; the endpoint itself is unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 12:44:40 -05:00
The_River ac5b2b7f6c Merge pull request 'feat: mount discovered module UI in a minimal Ekklesia browser host' (#23) from phase-6c/module-ui-host into main
CI / verify (push) Successful in 33s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/23
2026-08-22 14:09:32 +00:00
ekklesiaandClaude Opus 5 616c1d4834 feat: mount discovered module UI in a minimal Ekklesia browser host
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 43s
Phase 6C pass 2. Every link in the module UI chain existed except the one
that closes it: a module could be authored, built, validated, published at a
browser URL, and loaded by a generic loader — and nothing in Ekklesia did any
of it. There was no browser application at all.

Two pieces were missing. A browser could not discover what to mount, and it
must not work that out for itself.

GET /api/v1/module-ui is the inventory: per publishable module, its id and
version, the server-produced artifact URL, the server-produced root of the
module's own API, and the subset of that module's declared permissions the
caller holds. Five fields, each because building a ModuleUiHostContext needs
it — no filesystem paths, and no name/icon/folder/order, because navigation
metadata is a design that does not exist yet. Its module set is the ADR 0025
publication registry rather than a rescan, so the inventory and the artifact
route cannot disagree. It requires a new Core permission, core.modules.ui.read
(baseline authenticated), because it is precisely the enumeration the anonymous
artifact route deliberately withholds.

Both URLs are server-produced. moduleUiArtifactUrl already existed;
moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL
shape has exactly one author. Without it the shell would hardcode
/api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling,
reappearing in the host instead.

surfaces/shell (@ekklesia/shell) is the repository's first surface: a small
React application built to static files by its own esbuild script. It resolves
the principal, fetches the inventory, builds the host context entirely from
server-derived data, and hands the artifact URL to @ekklesia/module-ui-host.
One frame per selection — keyed by module identity — defeats the stale-load
race; the host owns the container, the loading state, and every failure, so a
broken module costs its own panel rather than the shell.

The shell statically imports no module, and both levels are checked: no source
import may name a module, and the emitted bundle must contain exactly one
dynamic import (the loader's) with no module id, no /api/v1/modules, no eval,
and no blob URL. Permission hints come from Core's own evaluator and stay
presentation-only — the end-to-end test has a UI holding no grant call its API
anyway and still receive 403.

Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts
is deliberately excluded, since its barrel pulls Drizzle's types and Node's
globals into the browser — the hole ADR 0024 closed one layer down.

Not the Dashboard: no navigation, grouping, icons, ordering, design system, or
routing. The selector is a list of module ids.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 08:55:44 -05:00
The_River 7a76ff6f26 Merge pull request 'feat: publish validated module UI artifacts at a platform-owned URL' (#20) from phase-6c/module-ui-asset-publication into main
CI / verify (push) Successful in 31s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 40s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/20
2026-08-22 13:19:25 +00:00
ekklesiaandClaude Opus 5 a02deb9433 feat: publish validated module UI artifacts at a platform-owned URL
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 41s
Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI
build pipeline and the generic host loader: Core resolves entrypoints.ui to an
absolute server filesystem path, and a browser needs a URL. This adds that
publication layer.

Core owns the policy. createModuleUiPublicationRegistry builds, from the
discovered and started module inventory, the answer to "may this be published,
and which exact file is it?" — publishable means the module survived discovery
and declares entrypoints.ui, with no new lifecycle state and no installer.

apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both
segments are lookup keys, not path components. An exact match yields the
canonical artifact path discovery already validated; no request value is ever
composed into a filesystem path, and the route declares no wildcard segment, so
traversal, encoded separators, sibling chunks, package-root files, and source
maps are unreachable by construction. An installed module directory never
becomes a static file root.

The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module
route segment, so a module registering GET /ui/:version would otherwise compete
with the platform for one URL with routing order deciding the winner.

The route requires no authentication, deliberately. Fetching the bundle confers
no authority, and a cookie-gated route would break cross-origin import(), which
sends none. The cost is stated rather than hidden: a caller who knows a module
id and its exact version can download that bundle. The three failure modes
return one identical 404 so the route is not an enumeration oracle.

Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match
handling, not immutable: without an installer nothing stops a version being
rebuilt with different bytes. The server reads bytes and writes bytes — never
imports, evaluates, or transforms the artifact.

Nothing mounts a module UI. A host could now obtain a URL, which was previously
impossible; there is still no Dashboard shell, navigation, or metadata endpoint.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 08:11:53 -05:00
The_River 82cbeef437 Merge pull request 'fix: require a compiled artifact for entrypoints.jobs' (#19) from fix/compiled-jobs-entrypoint into main
CI / verify (push) Successful in 31s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/19
2026-08-22 03:11:29 +00:00
ekklesiaandClaude Opus 5 3f9b5e3cb0 fix: require a compiled artifact for entrypoints.jobs
CI / verify (pull_request) Successful in 31s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 42s
Module discovery gated the compiled-ESM rule on an allowlist,
`["schema", "server", "ui"]`, so `entrypoints.jobs` accepted
`./src/jobs/index.ts`. Every other executable entrypoint required a built
`.js` or `.mjs`; `jobs` did not, purely because it was never added to
that list. It is the second entrypoint to slip through the same way —
`ui` did too, until Phase 6A added it explicitly — which is the argument
against the allowlist rather than against `jobs`.

The rule is now expressed as "every entrypoint except a directory
entrypoint", with `migrations` the only member of that set. A new
manifest entrypoint therefore defaults to the stricter validation instead
of silently escaping it. The same constant replaces the two existing
`=== "migrations"` special cases for the file/directory kind check and
its message, so the one thing that distinguishes `migrations` is stated
once. The directory message is now templated and produces byte-identical
text.

Every other entrypoint guarantee is untouched: package-relative paths,
no traversal, inside-package before and after symlink resolution, the
artifact must exist, and it must be a file.

No runtime was added. `entrypoints.jobs` still has no consumer — it is
declared, validated, and never executed — exactly as it has been since
ADR 0006, and as `ui` was before its host loader existed. The manifest
schema is unchanged and this stays within v4: the field, its type, and
its optionality are all the same, and only discovery's validation of the
declared path tightened.

Compatibility impact is nil in first-party code: no module, template, or
fixture declares a `jobs` entrypoint at all. A third-party module
declaring a source-TypeScript path would now fail discovery, which is the
intended correction.

Tests cover accepted `.js` and `.mjs`, rejected `.ts`/`.tsx`/`.cjs`, a
missing artifact, a directory, a symlink escaping the package, and that
discovery still never evaluates the artifact. The three rejection cases
were confirmed to fail against the previous implementation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 22:07:54 -05:00
The_River ff159a04dc Merge pull request 'feat: close the browser boundary and add the generic UI host loader' (#18) from phase-6b/module-ui-host into main
CI / verify (push) Successful in 55s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 41s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/18
2026-08-22 03:00:56 +00:00
ekklesiaandClaude Opus 5 44e676f0df feat: close the browser boundary and add the generic UI host loader
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 41s
Two gaps stood between the module UI contract and a host that could use
it. ADR 0023 recorded the first honestly but never diagnosed it: a module
UI file compiled with `"types": []` could still typecheck `process.env`.
The second was simply missing — nothing imported a compiled artifact,
checked its identity, mounted it, or tore it down.

The leak was one type-only import, bisected against the real type graph
rather than guessed. `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing
contracts/identity alone is clean and importing Zod alone is clean, but
contracts exposes a single barrel entry, so that one import reached
module-database, whose Drizzle types re-introduce Node's globals. The
runtime was never affected — the import was erased and host-context.js is
`export {}` — but a type-graph leak is still a real boundary failure,
because it silently permits code that cannot run in its target
environment.

The fix is not a narrower import path. The SDK now has no dependencies at
all, so there is no graph to leak through and no future contracts change
can reopen this. It owns `ModuleUiPrincipal`: the same shape ADR 0022
chose, with the Zod brand dropped. That brand encodes a server-side
parsing guarantee a browser cannot produce, and it is the same reason
`permissions.has` already took a plain string. An Ekklesia host still
passes its own `EkklesiaPrincipal` straight through — a branded string is
assignable to `string` — while a standalone host builds the literal.
Proven the way an author experiences it: two fixtures compiled under the
exact project a module uses, one supported and one deliberately reaching
for `process`, with the second required to fail on that specific name.

`@ekklesia/module-ui-host` is the generic loader, in its own package
because the SDK is bundled into every module artifact and host-only
loader code has no business shipping there. The boundary checker enforces
both directions: modules may not depend on the loader, and the loader may
depend only on the SDK plus dev-time tooling.

The distinction that shaped the design: Core resolves `entrypoints.ui` to
an absolute path on the server's disk, and a browser needs a URL. A
loader that accepted the path would test green on a server and fail in
every real host, and would quietly own asset publication. So the loader
takes a browser-loadable URL and knows nothing about how one is obtained.
Ekklesia has no route that publishes installed artifacts, so this is
precisely: loader complete, publication deferred. Nothing can mount an
installed module UI yet, and the docs say so rather than implying
otherwise.

Dynamic import is `(url) => import(url)` with a non-analyzable specifier,
kept as a runtime import so a host does not statically bundle every
installed module or need a hardcoded registry. It is injectable for
bundlers needing their own escape hatch, and deliberately not `eval` or
`new Function`, which would cost a host `unsafe-eval` in its CSP.

Identity is validated through the SDK's existing parser before `mount`
runs, so a stale or mismatched bundle never renders. Contract violations
propagate unwrapped; the loader adds only import and mount failure codes,
sanitized, with the cause attached. Unmount is idempotent, safe when a
module returned no cleanup, and marks an instance unmounted before
running cleanup so a throwing cleanup surfaces once and never re-runs. A
failed mount registers no cleanup. Each instance owns its own state, so
two mounts coexist. The loader never mutates the host's container — not
on success, failure, or unmount.

Tests prove all of it against real module URLs (`data:`, which both
browsers and Node import natively) rather than filesystem paths,
including one artifact built by the canonical Pass 2 pipeline and run
under both an Ekklesia-shaped and a standalone-shaped context. Core,
apps/api, and the contracts package are untouched; discovery remains
inert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 21:44:42 -05:00
The_River 5d75d910fd Merge pull request 'feat: build module UI into its declared browser artifact' (#17) from phase-6b/module-ui-build into main
CI / verify (push) Successful in 40s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 47s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/17
2026-08-22 02:28:59 +00:00
ekklesiaandClaude Opus 5 1e5866504e feat: build module UI into its declared browser artifact
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 26s
CI / migration-smoke (pull_request) Successful in 50s
ADR 0022 defined what a module UI is and selected React and TypeScript
as the authoring model, but closed by recording that it was not buildable
end to end: `browser.json` preserves JSX, bundler plugins are denied, and
nothing turned a module's `.tsx` into the artifact its manifest declares.
This closes exactly that gap.

esbuild produces the artifact. It was already the right answer before it
was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest
today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`,
so the pinned dependency adds no second copy and no new trust decision.
It is also the smallest tool that covers TSX, ESM, browser platform,
bundling, source maps, and a define-time constant with no plugin
ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which
the module boundary denies on purpose; `tsc` alone cannot bundle, so its
output would carry bare `react/jsx-runtime` imports no browser resolves.

React is bundled, not externalized. That follows from ADR 0022 rather
than from convenience: `mount()` was made the ABI precisely so React
would not be part of the host contract, and externalizing it would
reverse that through the build system — every host would have to supply
a compatible React, and a standalone host might not use React at all.
The cost is real and accepted: duplicate runtimes and a ~570 KB
unminified artifact. Sharing one runtime later is a build optimization
that cannot change the contract, because the contract is a mount
function.

Output is browser ESM at `es2022`, unminified so an operator can review
what they install, with `process.env.NODE_ENV` substituted — React reads
it at module scope and would otherwise touch a Node global that does not
exist in a browser. The output path is read from the module's own
manifest rather than a build convention, so declared and produced
artifacts cannot drift, and it is validated with the same
`packageRelativePathSchema` discovery enforces, so a build can never
write where discovery would later reject.

`@ekklesia/module-ui-build` owns every option; modules own none. It uses
the existing `tooling` role that modules could already depend on, so this
pass required no boundary relaxation at all. Its `bin` points at a
committed launcher rather than compiled output because pnpm links bins
during install, before Turborepo builds anything — a bin targeting
`dist/cli.js` fails to link on a fresh clone and breaks every module's UI
build in CI. A module with no `entrypoints.ui` is not an error; the CLI
reports nothing to build and exits zero, so one script serves both kinds
of module.

Styling is React inline style objects. A stylesheet import fails the
build rather than emitting a sidecar `.css` that no manifest declares and
no host loads — an undeclared asset would appear to work in tests and
silently do nothing in a real host.

`templates/module` is the canonical example and now ships optional UI.
Its tests build the artifact from source, validate it with
`parseModuleUiExport`, mount it into a real DOM, assert the rendered
output, and unmount it; the artifact is rebuilt per run so the proof can
never pass against stale output, and `dist/` stays gitignored. Because
verify runs both `pnpm test` and `pnpm build`, a UI that cannot be
produced fails CI through two independent paths.

One correction worth stating plainly: the UI TypeScript project declares
`"types": []`, and a UI file referencing `process` does fail in
isolation, but TypeScript still pulls Node globals in transitively — a
file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts`
and through its Zod and Drizzle types `process.env` typechecks again.
The docs and ADR describe this as the convention it is rather than the
guard it is not.

Core is untouched. Building is an authoring step; discovery still only
validates the artifact a module shipped and never executes or produces
one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 21:24:18 -05:00
The_River f9c1108527 Merge pull request 'feat: define the module UI authoring model and host contract' (#16) from phase-6b/module-ui-contract into main
CI / verify (push) Successful in 36s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/16
2026-08-22 02:02:07 +00:00
ekklesiaandClaude Opus 5 8d651c427d feat: define the module UI authoring model and host contract
CI / verify (pull_request) Successful in 30s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 45s
ADR 0021 established that a module may own UI and added a statically
validated, never-executed `entrypoints.ui` path, but deliberately left
five questions open: what the artifact exports, what a host provides,
how one artifact runs under both Ekklesia and a standalone host, what
the browser-side trust model is, and how module UI avoids being welded
to a Dashboard that does not exist. This answers them.

React and TypeScript are the supported module UI authoring model. The
repository already leaned this way: `packages/typescript-config` ships a
`browser.json` preset and a `react-native.json` preset with
`"jsx": "react-jsx"`, and ADR 0002 names browser and React Native
configurations. One framework is supported; every other stays rejected.

React is deliberately not part of the contract. A module's compiled
artifact default-exports `defineModuleUi({ id, version, mount })`, and
`mount(container, context)` takes a DOM element and returns an unmount
function. `id` and `version` are build-artifact identity markers a host
verifies against the manifest, mirroring `defineModuleRuntime` exactly.
A host needs an element and a context, never a matching rendering
library, so a module's framework, a host's framework, and the platform
boundary between them stay independent.

The contract lives in a new `@ekklesia/module-ui-sdk` rather than in
`@ekklesia/module-sdk`, for a concrete reason: that package re-exports
all of `@ekklesia/contracts`, so routing module UI through it would drag
Zod, Drizzle, and semver into every browser bundle. The new package has
no runtime dependencies, and its one dependency on contracts is
type-only — the emitted `host-context.js` is `export {};`, and a test
pins that so it cannot regress.

The host context is four members: `moduleId`, `api`, `principal`, and
`permissions`. Navigation, theme, module configuration, organization
context, and diagnostics are deliberately absent; each needs a design
that does not exist. Module UI reaches its backend through
`api.request(path)` with a path relative to its own API root and never
constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private
deployment detail — a module that builds that URL is silently unhostable
anywhere else. One fixture artifact is mounted under two hosts with
different origins and URL shapes to prove it.

Client-side permission hints are presentation only, and both the docs
and a test say so plainly: a denied hint does not stop the module from
calling its API, because server-side `requirePermission` is the only
authority. Module UI is trusted code in the host's own browser realm —
no iframe, no sandbox — which matches the backend model rather than
overstating it, since a module's server half already holds the API's
database authority.

The boundary guard is narrowed precisely, not removed: `.tsx`, `react`,
and `react-dom` are un-denied and modules may depend on the new
`module-ui-sdk` role. `.jsx`, `.css`, every other framework,
`react-router-dom`, `react-native`, and `@vitejs/plugin-react` stay
denied, layer rules are untouched, and an unsupported framework aliased
behind an allowed name (`"react": "npm:vue@3"`) is resolved and rejected
by its real target. Fixtures prove each case, including that module UI
source still cannot import Core.

Core, apps/api, and the manifest contract are untouched. Discovery still
validates the `entrypoints.ui` path and never evaluates it; hosts
perform their own dynamic import and validate the result with
`parseModuleUiExport`. No Dashboard shell, UI runtime, navigation,
grouping, standalone host, build or styling pipeline, installer,
hot-loading, or feature module is added.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 20:52:47 -05:00
The_River b5aa4371b6 Merge pull request 'feat: establish the module product contract' (#15) from phase-6a/module-product-contract into main
CI / verify (push) Successful in 29s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 43s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/15
2026-08-22 01:32:48 +00:00
ekklesiaandClaude Opus 5 44ba259f58 feat: establish the module product contract
CI / verify (pull_request) Successful in 2m31s
CI / infrastructure-smoke (pull_request) Successful in 15s
CI / migration-smoke (pull_request) Successful in 49s
Ekklesia's module model has been backend-only since ADR 0001: a module
owns domain behavior and data, and every presentation concern belongs to
a surface. That prevents a module author — the person who knows the
domain — from shipping the screen that makes their module useful, and it
guarantees a module and its own UI drift apart. It also pushes toward two
codebases for one domain the moment a module needs to run standalone.

ADR 0021 replaces that rule. A module is an independently versioned
product that owns its domain behavior, data, permissions, routes,
migrations, and its own UI, hostable inside Ekklesia and — from the same
implementation — by a later standalone host. Core keeps the containing
application shell and every platform policy: authentication,
authorization enforcement, platform context, top-level routing,
navigation, global theme, error boundaries, and trusted-origin/CORS. The
module graph stays flat: modules are peers, genuine module and capability
dependencies remain the only relationships, and Dashboard folders are
presentation configuration an administrator can reorganize, never module
architecture.

This pass implements the static contract only. `manifestVersion` moves to
4 and `entrypoints` gains one optional field, `ui`. Version 3's
`entrypoints` is strict and rejects `ui`, so this is an incompatible
shape change and version 3 is now rejected outright rather than
reinterpreted, exactly as version 1 was by version 2 and version 2 by
version 3. Every field version 4 adds is optional, so upgrading a
backend-only manifest is a one-character edit.

Discovery resolves and validates `ui` under the same rules as `server`
and `schema` — package-relative, inside the package before and after
symlink resolution, a real file, compiled ESM `.js`/`.mjs`, so source
TypeScript is rejected rather than assumed to be compiled later.
Discovery stays inert: nothing imports, evaluates, mounts, or renders a
UI entrypoint, and a test proves a throwing artifact still discovers
successfully. This follows `entrypoints.jobs`, declared and resolved
since ADR 0006 with no runtime consumer.

The name is `ui`, not `dashboard`, because a module's UI must be hostable
by Ekklesia's shell and by a standalone host, so the contract cannot be
named after one of them; and not `surface`, because `surfaces/*` is a
separate ownership layer. No widget, route, icon, folder, menu, layout,
or standalone-metadata field is added, and tests assert the manifest
still rejects every unknown entrypoint key and offers no hierarchy field.

Deliberately unchanged: `scripts/check-boundaries.ts` still enforces
`modules-do-not-own-ui`. Relaxing that guard depends entirely on the UI
authoring model a later pass chooses, and weakening a working protection
before that decision exists would trade real coverage for nothing. A
module may therefore declare `ui` today only for a framework-neutral
compiled artifact, and the docs say so rather than implying otherwise.
Restart-based discovery, explicit operator-invoked migrations, and the
trusted compiled-module model are untouched — a module that ships UI is
exactly as trusted as one that does not.

No Dashboard shell, UI runtime, renderer, framework choice, navigation
registry, grouping system, standalone host, installer, hot-loading, or
feature module is added by this pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 20:20:41 -05:00
The_River 94d26cffa0 Merge pull request 'phase-5/security-hardening' (#14) from phase-5/security-hardening into main
CI / verify (push) Successful in 27s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 42s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/14
2026-07-30 03:04:36 +00:00
ekklesiaandClaude Opus 5 288c088377 docs: close Phase 5D security hardening
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 42s
The documentation still described Phase 5D as in progress, Phase 6 as
blocked, and a disposable-runner isolation proof as a prerequisite for
opening the real pull request. All five passes and the independent
whole-repository re-audit have landed, so those statements no longer
match the accepted operating model.

The README's current status now records Phase 5D as complete under the
current trusted-contributor, not-yet-deployed threat model, with Phase 6
unblocked, and gains a pass 5 closure paragraph covering the re-audit,
the repository-local supply-chain and dependency fixes, the accepted
trusted-code-only CI boundary, and what remains required before
untrusted CI or a public launch. The pass 1-4 paragraphs keep their
technical content unchanged; only their tense changes, so each reads as
what was outstanding at the close of that pass rather than as a current
claim.

`platform-architecture.md` no longer stops at pass 1 or says passes 2-5
are unfinished; it summarizes all five passes and defers the remaining
operational limitations to the security document.

`security.md` states the trusted-code-only operating rule explicitly and
replaces "CI blockers before the real pull request is opened" with
"Requirements before executing untrusted contributions", framing the five
hardening items as prerequisites for relaxing that rule rather than as
Phase 6 blockers, and naming an isolated hosted-runner service as an
accepted alternative. The Gitea 1.24.2 warnings, the scope of
`persist-credentials: false`, and the unverified runner topology and
job-token authority are preserved verbatim; no external Gitea setting is
claimed. The launch checklist is marked as gating a public launch, not
development.

Both Doctor examples are corrected to the real three-migration output,
adding `core:0002_authorization_integrity · 634294731180`. The stale
sentence calling Core authorization the next architectural phase is
refreshed.

ADRs 0017-0020 are untouched: their statements that later passes were
outstanding are historical records made when each was accepted.

Documentation only — no production code, test, workflow, dependency,
migration, or schema change.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:59:26 -05:00
ekklesiaandClaude Opus 5 27088fc054 fix: run the TLS integration harness under a containerized job
CI / verify (pull_request) Successful in 28s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 41s
The `migration-smoke` job fails on the Gitea runner at
`docker port ekklesia-database-tls-<pid> 5432` with
`no public port '5432' published`; the later invalid-URL and
`ECONNREFUSED 127.0.0.1:5432` failures all cascade from it.

Gitea executes the job inside a container and drives sibling containers
through the runner's Docker daemon. The harness held two host-only
assumptions, both reproduced in a nested job container and fixed here.

Bind-mount source paths are resolved by the daemon against the runner
host's filesystem. The certificate directory is created in the job
container's `/tmp`, which does not exist there, so the daemon silently
creates an empty directory, the entrypoint's `cp` fails, the container
exits, and `docker port` reports no published port for an exited
container. The material is now transferred with `docker cp` into the
created-but-not-yet-started container, which crosses the daemon boundary
correctly in both topologies, and only the server leaf and its key are
transferred — both CA private keys stay in the harness directory.

A published port lands on the runner host's loopback, a different
network namespace from the job container's; measured, a container the
daemon published at `127.0.0.1:32907` is `ECONNREFUSED` at that address
from inside the job container. Under a containerized job the harness now
publishes nothing and joins both containers to one isolated user-defined
network, reaching PostgreSQL by container DNS on 5432. Direct-host runs
keep a random port pinned to loopback via Docker's `127.0.0.1::5432`
form. `compose_job_network_job_container` exports the topology decision
the integration script already made, so there is still exactly one
detector.

Every TLS assertion is preserved, and the certificate identity is
generated from the host actually connected to — `localhost` directly, the
container's DNS name otherwise. The mismatch and chain-only controls use
the TLS container's address on the isolated network rather than a
hardcoded one, and the certificate carries no IP SAN, so verify-full
still fails with `ERR_TLS_CERT_ALTNAME_INVALID`.

Correcting one measurement in passing: chain-only verification does not
skip the identity check. For an IP host `pg` omits `servername` and hands
`tls.connect` a bare socket with no host, and `net.Socket._host` is null
for an IP literal, so Node checks the fixed fallback name `localhost` —
never the address dialled. A certificate whose only SAN is
`DNS:example.invalid`, reached chain-only over `127.0.0.1`, is rejected
with `Host: localhost. is not in the cert's altnames`. The direct-host
control therefore only ever passed because its SAN was `DNS:localhost`.
The SAN set now always includes that fallback name so the control proves
the same gap in both topologies, and the stale claim is corrected in the
transport doc comment. No production behavior changes.

Teardown also removes the container's anonymous volume, the isolated
network, and the job container's attachment, after success and failure
alike.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:42:26 -05:00
ekklesiaandClaude Opus 5 a2db774b8c docs: correct Gitea Actions guarantees
CI / verify (pull_request) Successful in 29s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Failing after 39s
The security document claimed the CI workflow "is least privilege" because it
declares `permissions: contents: read`. That is not true on the server this
repository actually uses.

The inventoried server is Gitea 1.24.2, and its official comparison page lists
`permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
`jobs.<job_id>.timeout-minutes` under unsupported workflow syntax — verified
against https://docs.gitea.com/1.24/usage/actions/comparison rather than assumed.
On this server those keys are inert, so the branch cannot claim the in-job
GITEA_TOKEN is read-only, and cannot claim YAML-enforced timeout or concurrency
behavior. Job-token permission controls, repository/owner defaults, and
server-side maximum-permission clamping arrive in the 1.26 feature line
(https://docs.gitea.com/1.26/usage/actions/token-permissions).

The document now separates what is actually true of this repository — full-SHA
action pins, frozen-lockfile install, no pull_request_target, no referenced
secret, `persist-credentials: false` on every checkout, and the declared
`permissions` block — from what the server enforces. It also states precisely
what `persist-credentials: false` proves: checkout leaves no credential in
`.git/config` for a later step, which is not the same as removing or reducing
the job token. The CI blockers are recorded: upgrade to a reviewed supported
release, configure repository/owner defaults and a maximum permission, prove
against the real server what the token cannot do, move timeout/cancellation/
teardown into the trusted provisioner and reaper, and finish the disposable-guest
proof before opening the real pull request.

The workflow keeps every declaration — they are already correct for a supported
Gitea release and for GitHub Actions — and gains a comment saying plainly that
this server ignores them, so no future reader mistakes them for enforcement.

Nothing about runners, proxies, firewalls, or deployment is asserted here.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 20:10:20 -05:00
ekklesiaandClaude Opus 5 cda841f8c7 test: gate the graceful drain proof on handler entry
The Pass 4 drain test waited for `server.getConnections() > 0` before starting
shutdown, and a comment described that as the request having "demonstrably
entered". It had not: the connection count proves the server accepted a TCP
connection, nothing about whether Hono had dispatched to a handler. Run five
times against the real integration harness it was not flaky, but the claim was
stronger than the evidence, so the evidence is now stronger instead.

A fixture module's route handler announces entry through a `globalThis` gate —
the same channel the existing lifecycle recorders use — and then blocks until the
test releases it. Shutdown therefore begins at a point where the handler is
provably inside user code. The assertions are now about recorded ordering rather
than timing: the handler finished, the module stopped, and `handler-finished`
precedes `stop` in the recorded events, with the full 200 response and a clean
transport close. No sleeps; the gate is released in `finally` so a failed
assertion cannot hang the suite.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:53:25 -05:00
ekklesiaandClaude Opus 5 07a3c61083 docs: define deployment trust boundaries
One canonical security document, linked from the README, platform architecture,
and module-development guide, stating what the boundaries actually protect
against — and, more importantly, what they do not.

Installed modules are trusted compiled backend code. A module runs arbitrary
JavaScript inside the API process and shares its PostgreSQL role and pool, so
`ModuleDatabase.execute` gives it the API's full database authority over every
table, including identity, grants, owner, bootstrap, and audit. Table prefixes,
manifests, permission declarations, route validation, frozen contexts, typed
facades, and the capability registry are correctness and coordination boundaries
that assume a cooperative module; none of them constrains one that is not. There
is no process, container, syscall, filesystem, network, database-role, schema,
tenant, or resource sandbox between a module and Core, and the document says so
in those words, along with what would have to be built before untrusted modules
could be accepted.

It also records the honest deployment state: Ekklesia is not deployed. There is
no production instance, reverse proxy, public listener, or deployment automation
anywhere in this repository, and no `.env` has ever been committed. The loopback
listen default and the production signup refusal are real, but a config default
is not a firewall, so the origin-reachability, proxy-overwrite, header-spoofing,
and trusted-peer properties a public launch needs are written as a
launch-blocking checklist rather than as guarantees. The per-process
authentication limiter is described as exactly that, valid only while signup
stays disabled and exactly one instance serves traffic.

The CI section records the workflow's verified posture and deliberately stops
there: a pull request can edit `runs-on`, so a workflow file cannot be the
boundary, and the runner topology is not described as though it had been
verified.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:49:12 -05:00
ekklesiaandClaude Opus 5 f7621a599f fix: remediate dependency advisories
Three advisory paths, closed with the narrowest scoped overrides rather than a
broad upgrade. Each raises exactly the one edge that resolved a vulnerable
version, and each pins an exact release.

PostCSS 8.5.17 (GHSA-r28c-9q8g-f849, high) was reached only through Vite, which
better-auth pulls in via Vitest, and Vite 8.1.4's own range still resolved the
vulnerable version. `vite>postcss` now resolves 8.5.24; one version in the tree.

esbuild 0.18.20 (GHSA-67mh-4wv8-2f99, moderate) was reached only through the
deprecated @esbuild-kit/core-utils, whose pinned range predates the fix. Scoped
to `@esbuild-kit/core-utils>esbuild` on purpose: drizzle-kit already resolved
esbuild 0.25.12 directly and tsx/vite resolve 0.28.1, so a global override would
have dragged those newer, unaffected versions backwards. The vulnerable version
is gone and 0.28.1 is preserved.

Lodash 4.17.21 (GHSA-r5fr-rjxr-66jc high, GHSA-f23m-r3pf-42rh and
GHSA-xxjr-mmjv-4gpg moderate) was reached only through the development-time
Better Auth `auth` CLI's Prisma parser. All three chevrotain packages pin lodash
to exactly 4.17.21, so no in-range release is patched, and the advisories
require >= 4.17.24 — a version the 4.17.x line never published. 4.18.1 is the
lowest fixed release; the override crosses chevrotain's exact pin for that
reason. Verified compatible before applying: 4.18.1 keeps the per-method
CommonJS subpath files chevrotain requires (`lodash/assign`, `lodash/isRegExp`,
and the rest), with the same `main` and no restrictive `exports` field.

Both `pnpm audit --prod` and the full `pnpm audit` now report no known
vulnerabilities, with no ignore, allowlist, or audit-level suppression.

Proven, not assumed: `pnpm install --frozen-lockfile` succeeds; the Better Auth
schema generator still matches the checked-in identity schema, which exercises
the very chevrotain/lodash path that was overridden; drizzle-kit loads the real
config and reports no schema changes against a scratch copy, leaving tracked
migration artifacts untouched; the three Core migrations keep their exact
checksums; and the whole unit and PostgreSQL integration suites stay green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:49:12 -05:00
ekklesiaandClaude Opus 5 51f4075fd6 ci: stop persisting the job token
`actions/checkout` defaults to `persist-credentials: true`, which writes the job
token into `.git/config` and leaves it there for every later step. Nothing in
this workflow pushes, so the credential has no use after checkout — but a pull
request can edit this file, and any step it adds runs in the same workspace.

All three jobs now check out with `persist-credentials: false`. The rest of the
workflow's posture is unchanged and was re-verified: top-level
`permissions: contents: read`, both actions pinned by full commit SHA,
`pnpm install --frozen-lockfile`, no `pull_request_target`, and no secrets
referenced anywhere.

This is the one property from the Pass 5 runner-isolation requirements that is
repository-local and statically verifiable. The rest — whether pull-request jobs
run on a disposable guest, what Docker daemon they reach, and which runners this
repository can select — depends on the actual Gitea and runner hosts and is not
claimed here.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 19:49:12 -05:00
393 changed files with 72755 additions and 1006 deletions
+90 -1
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
@@ -12,10 +16,95 @@ POSTGRES_PASSWORD=ekklesia-development-only
POSTGRES_PORT=5432 POSTGRES_PORT=5432
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
# Ekklesia's object-storage provider (ADR 0030). Absent S3_ENDPOINT means this installation has no
# object storage, which is a complete deployment: only a module that declares a logical bucket in its
# manifest is affected, and it is reported blocked rather than breaking anything. Production requires
# https, exactly as the database transport requires verified TLS.
#
# These are host infrastructure. No module ever sees them: a module names a logical bucket and the
# host resolves the binding.
S3_ACCESS_KEY=ekklesia-development S3_ACCESS_KEY=ekklesia-development
S3_SECRET_KEY=ekklesia-development-secret S3_SECRET_KEY=ekklesia-development-secret
S3_BUCKET=ekklesia-development
S3_ENDPOINT=http://127.0.0.1:8333 S3_ENDPOINT=http://127.0.0.1:8333
S3_PORT=8333 S3_PORT=8333
S3_REGION=us-east-1 S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true S3_FORCE_PATH_STYLE=true
# The origin a *browser* reaches object storage on, used only when signing temporary read URLs for
# direct delivery (ADR 0031). Leave it unset whenever S3_ENDPOINT is itself reachable from a browser,
# which is the case for this development stack: Compose publishes the service on loopback, so the
# endpoint above serves both roles and direct delivery works with no extra configuration.
#
# Set it when Core reaches storage by a name a browser cannot resolve — a Compose service name such
# as http://object-storage:8333, for instance — and browsers reach it through a public origin:
# S3_DELIVERY_ENDPOINT=https://storage.example.org
# It must be the origin the browser actually requests. An S3-compatible signature covers the request
# Host, so a URL signed for one origin cannot be rewritten to another afterwards; a reverse proxy in
# front of storage must therefore pass the original Host header through unchanged. The scheme is not
# signed, so terminating TLS at that proxy and forwarding plaintext to the service is fine.
# S3_DELIVERY_ENDPOINT=
# Namespaces every physical bucket this installation owns, so several Ekklesia-compatible deployments
# can share one S3-compatible service. Optional; defaults to "ekklesia".
S3_BUCKET_PREFIX=ekklesia
# Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider —
# physical buckets are derived from a module's manifest declaration, not configured here.
S3_BUCKET=ekklesia-development
# Ekklesia's outbound email transport (ADR 0037). Absent EKKLESIA_SMTP_HOST means this installation
# sends no email, which is a complete deployment: nothing in the platform sends any yet, and a
# future feature that needs to is told so rather than crashing.
#
# These are host infrastructure. No module ever sees them, and no feature chooses its own sender:
# the From address below is the only one this platform can send as.
#
# EKKLESIA_SMTP_HOST is a bare hostname — never a URL, a port, or a connection string.
# EKKLESIA_SMTP_SECURITY is exactly one of:
# implicit-tls TLS from the first byte, conventionally port 465
# starttls plaintext connect then a *required* upgrade, conventionally 587 (the default)
# plaintext no TLS at all; development only, and production refuses it outright
# EKKLESIA_SMTP_PORT is optional and defaults to the conventional port for the mode.
#
# Credentials are optional **as a pair**, because a relay authorized by source IP has neither.
# Setting one without the other is refused rather than silently connecting anonymously. Never commit
# a real password: this file is in every clone.
#
# A Google Workspace SMTP Relay deployment typically looks like:
# EKKLESIA_SMTP_HOST=<the relay host from Google Workspace Admin>
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# [email protected]
# Whether it also needs a username and password depends on the relay's authorization setting in
# Google Workspace Admin, which varies per deployment. See docs/development/email.md.
# EKKLESIA_SMTP_HOST=
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# EKKLESIA_SMTP_USERNAME=
# EKKLESIA_SMTP_PASSWORD=
# [email protected]
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
#
# EKKLESIA_SMTP_HELO_NAME is the name Ekklesia announces itself as in the SMTP EHLO command.
#
# Leave it unset unless a relay refuses the connection. An SMTP client has to identify itself, and
# with nothing configured Nodemailer derives that from the machine hostname — substituting the
# address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal
# state of a container or an unnamed VM. Some relays close the connection on that greeting with a
# 421 before any message is offered, which looks exactly like the relay being unreachable.
#
# Set it to a fully qualified domain name your deployment owns — typically the sending domain.
# No scheme, no port, no brackets, and at least one dot; anything else is refused at startup.
# EKKLESIA_SMTP_HELO_NAME=example.org
# Where an emailed verification or password-reset link sends a browser (ADR 0038).
#
# Absent means the Dashboard is served from EKKLESIA_IDENTITY_BASE_URL, which is the ordinary
# same-origin production shape. Set it only when a deployment separates the two, for example an API
# at https://api.example.org and the Dashboard at https://dash.example.org. It must also appear in
# EKKLESIA_IDENTITY_TRUSTED_ORIGINS, or the identity system refuses the emailed link — Ekklesia
# checks that at startup rather than letting every recipient discover it.
#
# This is the authenticated Dashboard host, never the public church website.
# EKKLESIA_DASHBOARD_ORIGIN=https://dash.example.org
#
# Verification and password recovery need a working relay above. The initial platform-owner
# bootstrap (pnpm identity:create-initial-owner) deliberately does not: it creates that one account
# already verified, so a fresh server can be signed in to before any mail exists.
+32
View File
@@ -1,5 +1,22 @@
name: CI name: CI
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
# correct on a supported release and on GitHub Actions, but on this server they are inert:
#
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
# is upgraded and configured, the token's authority is whatever the server grants — not what
# this file requests.
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
#
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
# itself. See docs/development/security.md.
on: on:
pull_request: pull_request:
push: push:
@@ -20,6 +37,11 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -44,6 +66,11 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Exercise PostgreSQL and S3 infrastructure - name: Exercise PostgreSQL and S3 infrastructure
run: bash scripts/smoke-infrastructure.sh run: bash scripts/smoke-infrastructure.sh
@@ -55,6 +82,11 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+10 -6
View File
@@ -12,12 +12,16 @@ monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration - **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
feature module, a surface, or an app. feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are backend-only capability packages. - **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
They own their own data and migrations, declare capabilities/permissions through packages. They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never contain UI or import Core, `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
another module, or a surface directly. surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client primary and full-viewport display artifacts, authored in React against the same role-neutral
contracts only, never Core or module internals directly. `@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
presentation concern a module does not. They consume public API/client contracts only, never Core
or module internals directly.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and documented. Read `docs/architecture/repository-boundaries.md` and
+46 -5
View File
@@ -9,7 +9,8 @@ Before opening a change, run:
pnpm verify pnpm verify
``` ```
Migration-runner changes also require the disposable live PostgreSQL suite: Migration-runner changes and changes to identity/authentication or the API's session transport also
require the disposable live PostgreSQL suite:
```bash ```bash
pnpm test:migrations:integration pnpm test:migrations:integration
@@ -33,10 +34,50 @@ pnpm infrastructure:config
- Expose supported entry points through package `exports`; do not import another package's - Expose supported entry points through package `exports`; do not import another package's
private source paths. private source paths.
- Use `workspace:` ranges for internal package dependencies. - Use `workspace:` ranges for internal package dependencies.
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other - Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
UI source files. contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
surface. other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
presentation-mode flag, and the same versioned mount ABI.
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
stays rejected inside a module. Reference registered semantic tokens; never define an
`--ekklesia-*` custom property of your own.
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
private deployment detail and would break standalone hosting.
- The containing application shell, navigation, global theme, and platform policy belong to Core and
the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
through `GET /api/v1/module-displays/<module-id>`, mounts both with
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
- A module may contribute a browser renderer to a capability another module provides
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
ordering. A contributed renderer gets its own id and that input, and nothing else.
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
display-scoped service worker caches the shell and declared artifacts only, network-first, and
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
module, child module, owning module, or group-derived dependency: real module relationships are
the dependency contract's job alone, and grouping must never influence discovery, dependency
ordering, startup, or authorization (ADR 0027). A module declares its display name
(`manifest.name`); the Dashboard owns where it appears.
- A surface is browser code. It may depend on browser-safe shared roles only — never
`@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the
browser — and it reaches the server over HTTP.
- Add or update tests when changing a contract or architectural rule. - Add or update tests when changing a contract or architectural rule.
- Record major architectural changes in an ADR rather than burying them in an implementation. - Record major architectural changes in an ADR rather than burying them in an implementation.
+777 -32
View File
@@ -1,21 +1,32 @@
# Ekklesia # Ekklesia
Ekklesia is an open, modular church technology platform for composing backend capabilities Ekklesia is an open, modular church technology platform. Each module is an independently versioned
into independently developed dashboards, websites, mobile apps, kiosks, displays, and other product that owns its domain behavior, data, and its own UI, composed into dashboards, websites,
surfaces. mobile apps, kiosks, displays, and other surfaces.
## Current status ## Current status
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP
transport), and Phase 5C (module database access) have landed. Phase 5D (public request perimeter transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
hardening against an independent security audit) is in progress; pass 1 of 5 has landed — see against an independent security audit) is complete: all five passes have landed and the final
below. **Phase 5D is not complete, and Phase 6 (Surface work) remains blocked until every audit independent whole-repository re-audit has closed, under the current trusted-contributor,
finding is resolved and a final re-audit passes.** The repository includes not-yet-deployed threat model.** Phase 6 completed through 6D, and **Phase 7A passes 1 (the minimal
operational module lifecycle), 2 (the
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
kiosk host) have landed. Phase 7B pass 1 adds the Dashboard's host-owned browser sign-in, sign-out,
session-expiry handling, and authenticated-shell transition.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates ongoing product
development; see the
pass 5 closure below and
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes
public module contracts, independently versioned capabilities, static module manifests, public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry, deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
schema aggregation, and a cloneable backend-only module template. Migration artifacts have schema aggregation, and a cloneable module-product template with optional primary and display UI.
Migration artifacts have
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
declarations, and exact checksums. Core provides read-only applied-history planning and a locked, declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
@@ -41,10 +52,13 @@ order never grants elevation — but is now **disabled by default, and cannot be
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and [API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
**No email verification, password recovery, or Dashboard exists yet** — that, along with Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
production approval-provider composition, jobs, physical storage provisioning, surfaces, and enrollment still do not.
feature modules, remains later vertical slices. Public registration is not yet ready for a real Public registration remains disabled by default and prohibited in production — and a fresh
public launch. production installation now has a supported way to get its first administrator anyway: Phase 7B
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
but not a finished product UI or a production asset-hosting deployment; jobs, production
approval-provider composition, feature modules, and the public Website remain later slices.
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
for platform owners, explicit grants, and relationship-derived grants; a smallest-possible for platform owners, explicit grants, and relationship-derived grants; a smallest-possible
@@ -56,9 +70,9 @@ on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint
accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). [ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
Phase 5A wires module-declared permissions into that same authorization system: a version-3 module Phase 5A wires module-declared permissions into that same authorization system: the module manifest
manifest declares permissions as complete `{id, baseline, description}` declarations (never a bare (version 3 at the time, version 6 today) declares permissions as complete
string), and `apps/api` discovers every installed module and composes one immutable platform `{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
permission registry — Core's own declarations plus every module's — before constructing its permission registry — Core's own declarations plus every module's — before constructing its
database pool, Better Auth instance, or HTTP server. A module's declared permission is then database pool, Better Auth instance, or HTTP server. A module's declared permission is then
enforceable and grantable through the exact same `requirePermission` middleware and enforceable and grantable through the exact same `requirePermission` middleware and
@@ -113,9 +127,10 @@ transport-level failures are observed and logged in sanitized form. See
[API](docs/development/api.md), [Module development](docs/module-development/README.md#http-routes), [API](docs/development/api.md), [Module development](docs/module-development/README.md#http-routes),
and [ADR 0017](docs/architecture/decisions/0017-public-request-perimeter-hardening.md). **This pass and [ADR 0017](docs/architecture/decisions/0017-public-request-perimeter-hardening.md). **This pass
does not add distributed rate limiting, durable public-signup abuse control, or any deployment/TLS does not add distributed rate limiting, durable public-signup abuse control, or any deployment/TLS
guarantee, and it does not complete Phase 5D** — passes 2–5 (identity/database transport hardening, guarantee, and it did not by itself complete Phase 5D** — at the close of this pass, passes 2–5
authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure (identity/database transport hardening, authorization integrity, module/runtime/migration
plus a final independent re-audit) remain outstanding before Phase 6 (Surface work) may begin. correctness, and deployment/supply-chain closure plus a final independent re-audit) were still
outstanding, and Phase 6 (Surface work) was blocked until they landed.
Phase 5D pass 2 of 5 corrects identity session resolution and enforces a database transport policy. Phase 5D pass 2 of 5 corrects identity session resolution and enforces a database transport policy.
A request carrying no cookies, or only unrelated cookies, now performs **no** identity database A request carrying no cookies, or only unrelated cookies, now performs **no** identity database
@@ -135,9 +150,9 @@ which the pinned driver would otherwise let silently override the policy. See
[ADR 0018](docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md). [ADR 0018](docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
**This pass adds no secret store, no certificate rotation, no client-certificate authentication, and **This pass adds no secret store, no certificate rotation, no client-certificate authentication, and
no distributed rate limiting; TLS protects the connection, not the exposure, so the network boundary no distributed rate limiting; TLS protects the connection, not the exposure, so the network boundary
remains the deployment's own responsibility. Phase 5D is still not complete** — passes 3–5 remains the deployment's own responsibility. This pass did not by itself complete Phase 5D** — at
(authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure its close, passes 3–5 (authorization integrity, module/runtime/migration correctness, and
plus a final independent re-audit) remain outstanding. deployment/supply-chain closure plus a final independent re-audit) were still outstanding.
Phase 5D pass 3 of 5 preserves authorization security history and makes explicit-grant management Phase 5D pass 3 of 5 preserves authorization security history and makes explicit-grant management
unambiguous and bounded. Authorization **audit history now survives user deletion**: the audit unambiguous and bounded. Authorization **audit history now survives user deletion**: the audit
@@ -159,9 +174,9 @@ unsigned position cursor. One forward Core migration (`0002_authorization_integr
[ADR 0019](docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md). [ADR 0019](docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
**This pass adds no roles, teams, tenants, service accounts, scope resolver, user-deletion endpoint, **This pass adds no roles, teams, tenants, service accounts, scope resolver, user-deletion endpoint,
or owner-recovery workflow; audit append-only behavior remains an application convention, not a or owner-recovery workflow; audit append-only behavior remains an application convention, not a
database or cryptographic guarantee. Phase 5D is still not complete** — passes 4 (module/runtime/ database or cryptographic guarantee. This pass did not by itself complete Phase 5D** — at its close,
migration correctness) and 5 (deployment/supply chain plus a final independent re-audit) remain passes 4 (module/runtime/migration correctness) and 5 (deployment/supply chain plus a final
outstanding, and Phase 6 remains blocked. independent re-audit) were still outstanding, and Phase 6 was blocked until they landed.
Phase 5D pass 4 of 5 hardens the module runtime and the migration commands. **Two routes of one Phase 5D pass 4 of 5 hardens the module runtime and the migration commands. **Two routes of one
module that could both match the same request path are now rejected at registration** module that could both match the same request path are now rejected at registration**
@@ -186,8 +201,666 @@ string literal, dollar-quoted body, quoted identifier, or comment. See
**Deadlines bound how long the platform waits; they do not cancel or sandbox trusted in-process **Deadlines bound how long the platform waits; they do not cancel or sandbox trusted in-process
module code, and closing a socket does not prove a handler promise ended. No custom SQL parser module code, and closing a socket does not prove a handler promise ended. No custom SQL parser
exists. Modules remain trusted compiled backend code with no process, role, schema, or tenant exists. Modules remain trusted compiled backend code with no process, role, schema, or tenant
isolation. Phase 5D is still not complete** — pass 5 (deployment/supply chain plus an independent isolation. This pass did not by itself complete Phase 5D** — at its close, pass 5
whole-repository re-audit) remains outstanding, and Phase 6 remains blocked. (deployment/supply chain plus an independent whole-repository re-audit) was still outstanding, and
Phase 6 was blocked until it landed.
Phase 5D pass 5 of 5 closes the phase with an independent whole-repository re-audit and the
repository-local half of deployment and supply-chain hardening. The re-audit swept every layer —
Core, contracts, `apps/api`, scripts, the CI workflow, and documentation — and its repository-local
findings are resolved here: every dependency advisory is remediated (both `pnpm audit` and
`pnpm audit --prod` report no known vulnerabilities), every CI action reference is pinned to a full
immutable commit SHA, installs run `--frozen-lockfile`, no workflow references a repository or
instance secret, every checkout step sets `persist-credentials: false`, a checksum-verified secret
scan found no committed credential in the history, and one canonical
[security document](docs/development/security.md) now states the trusted-module model, the
undeployed state, and the launch checklist in a single place. **CI is accepted as a
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
execute on it automatically. What remains is operational and cannot be proven from this repository —
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
gates a **public launch**; moving future public contribution workflows to a service with isolated
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
development proceeds under the current trusted-contributor, not-yet-deployed model. Runner topology
and job-token authority remain unverified and are not described as isolated. See
[Security model and deployment trust boundaries](docs/development/security.md).
Phase 6A established the **module product contract**. Ekklesia's modules are no
longer backend-only capability packages: a module is an independently versioned *product* that owns
its domain behavior, data, permissions, routes, migrations, and — new in this pass — **its own UI**.
Core keeps the containing application shell and every platform policy (authentication, authorization
enforcement, platform context, top-level routing, navigation, global theme, error boundaries,
trusted-origin/CORS). Modules stay **flat peers**: `video`, `livestream`, and `announcements` are
independent first-class modules with no parent/child relationship, and a Dashboard folder such as
`Media` is presentation configuration an administrator can reorganize, never module architecture and
never a dependency. The long-term goal is one module implementation hosted by Ekklesia *and*, later,
by a standalone application — never two codebases for one domain. See
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
**What that pass shipped at its checkpoint was the static contract only.** `manifestVersion: 4`
added one optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
during discovery exactly like `server` and `schema` — inside the package, real file, no source
TypeScript. Discovery stayed inert: **nothing imported, mounted, or rendered that entrypoint**,
exactly like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest was a
one-character edit; version 3 was rejected outright rather than reinterpreted, as version 2 was
before it. The Dashboard shell, runtime mounting, and navigation named as absent at that checkpoint
landed in subsequent Phase 6 passes. A standalone host, module installer or marketplace, hot-loading,
and every real feature module still do not exist. Restart-based discovery, explicit operator-invoked
migrations, and the trusted compiled-module model are unchanged.
Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
and what a host gives it.** Module UI is authored in **React and TypeScript**, and a module package
may now contain `.tsx` and depend on `react`/`react-dom` — every other UI framework stays rejected by
`pnpm boundaries`, and the layer rules are untouched, so module UI still cannot import Core, an app,
a surface, or another module. React is the supported *authoring* model but is deliberately **not** in
the contract: a module's compiled `entrypoints.ui` artifact default-exports
`defineModuleUi({ id, version, mount })`, and `mount(container, context)` receives a DOM element and
returns an unmount function. A host needs an element and a context, never a matching framework.
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and
`realtime` — and nothing else. **A module UI asks its host for its own API by relative path**
(`api.request("posts")`)
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
hints are presentation only** — server-side `requirePermission` remains the sole authority — and
**module UI is trusted code in the host's own browser realm**: no iframe, no sandbox, matching the
backend trust model rather than pretending to improve on it. See
[ADR 0022](docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md).
Core still never loads browser code: discovery validates the `entrypoints.ui` path and never
evaluates it, and hosts do their own dynamic import.
Phase 6B pass 2 makes that contract **buildable end to end**. A module author writes React and
TypeScript at `src/ui/index.tsx` and runs its normal `pnpm build`; **esbuild** compiles it to the
browser ESM artifact the manifest declares. Authors write no bundler configuration — the new
`@ekklesia/module-ui-build` package owns all of it, and because it uses the existing `tooling` role
that modules could already depend on, **this pass required no boundary relaxation at all**. The
output path is read from the manifest rather than a build convention, so the declared and produced
artifacts cannot drift.
**React is bundled into each artifact rather than shared from the host.** That is the decision ADR
0022 implies: `mount()` is the ABI precisely so React is *not* part of the host contract, and
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
sharing one runtime later is a build optimization that does not change the contract. Styling is React
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
and installing a module or starting the API never triggers one.
`templates/module` is the canonical example and now ships optional UI. Its tests build the artifact
from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered
output, and unmount it — so a UI that cannot be produced fails CI, and no generated file is ever
committed. See [ADR 0023](docs/architecture/decisions/0023-module-ui-build-pipeline.md).
Phase 6B pass 3 closes the last two foundational gaps. **The browser boundary now actually holds**:
`@ekklesia/module-ui-sdk` had one type-only import of `@ekklesia/contracts`, whose barrel pulled
Drizzle's types — and with them Node's globals — into every module UI program, so `process.env`
typechecked inside browser-only code. The SDK now has **no dependencies at all**, and owns a plain
browser `ModuleUiPrincipal` instead of Core's Zod-branded server type; a compile test proves that
supported UI source compiles and that `process` does not. **And there is now a generic host loader**:
`@ekklesia/module-ui-host` imports a published artifact from a browser URL, validates its id and
version against the manifest *before* mounting, mounts it into a host-owned container, and returns an
idempotent `unmount()`. A module may never depend on it — it is host infrastructure, and the boundary
checker enforces that. See
[ADR 0024](docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md).
Phase 6C pass 1 builds the publication layer those passes were missing. Core resolves
`entrypoints.ui` to a path on the *server's filesystem*, and a browser needs a **URL**; the platform
now serves one:
```text
GET /api/v1/module-ui/<module-id>/<version>
```
`:moduleId` and `:version` are **lookup keys, not path components**. They are matched against a
publication registry Core builds from the discovered, started module inventory, and only an exact
match causes the already-canonical artifact path discovery validated to be read. No request value is
ever joined into a filesystem path and the route declares no wildcard segment, so traversal, encoded
separators, sibling chunks, package-root files, and **source maps** are unreachable by construction —
**an installed module directory never becomes a static file root**, and only the single artifact its
manifest declares is served. A module that is not installed, one that declares no UI, and a wrong
version return one identical `404`; an artifact that vanished after discovery returns a sanitized
`503` carrying no path. The prefix is a *sibling* of `/api/v1/modules/`, not a child, because `ui` is
a legal module route segment and a module could otherwise collide with the platform.
**The route requires no authentication, deliberately.** Fetching the bundle confers no authority —
every module API stays behind `requirePermission`, and the host, not the artifact, supplies the
principal — while requiring a cookie would break cross-origin `import()`, which sends none. The cost
is stated plainly: a caller who already knows a module id *and* its exact installed version can
download that bundle. Caching is `no-cache` with a strong `ETag` rather than `immutable`, because
without an installer nothing stops a version being rebuilt with different bytes. See
[ADR 0025](docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md).
Phase 6C pass 2 connects the chain: **Ekklesia now has a browser host that mounts module-owned UI.**
`surfaces/shell` (`@ekklesia/shell`) is the repository's first surface — a small React application,
built to static files with esbuild — and it does the whole loop at run time:
```text
GET /api/v1/principal → who is viewing
GET /api/v1/module-ui → which module UIs may be mounted, and where
↓ server-produced artifact URL
@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container
↓
context.api.request("records") → /api/v1/modules/example/records
context.realtime.subscribe("state") → host-owned live state transport
```
The inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
artifact URL, module API root, module realtime root, and the viewer's granted permissions **for that
module** — and nothing else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because
navigation metadata is a separate design. It is authenticated (`core.modules.ui.read`, baseline
`authenticated`) precisely because it is the module enumeration the anonymous artifact route
deliberately withholds. All three URLs are produced by the server's own canonical helpers, so the
browser never rebuilds Ekklesia's module prefixes and any can change without touching host or module
code.
**The shell statically imports no module.** Every module it can mount arrives at run time from the
inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no
module id, no `/api/v1/modules`, no `eval`, and no blob URLs. Permission hints come from the server's
own evaluator and remain presentation only: the end-to-end test proves a viewer holding no grant is
still refused `403` when the UI calls the API anyway. The host owns the container, the loading state,
and every failure; a broken module costs its own panel, not the shell. See
[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md).
Phase 6D pass 1 gives that shell a real **navigation model** — and pins down the rule it could
easily have broken. A church sees `Media → Livestream, Video` and `Planning → Calendar`, while the
modules underneath stay exactly what they were: flat, independently versioned products with no
parent, no owner, and no folder-derived dependency.
**Grouping is presentation configuration, not module structure.** A group is a label on a rendering
bucket: no manifest, no version, no artifact, no API, no lifecycle, and nothing depends on it.
Placing Livestream and Video in one folder creates no relationship between them, and moving a module
changes one row. *Livestream depends on Video because it calls Video's functionality* is a real
dependency that orders startup; *Livestream is shown under Media* orders nothing. That distinction is
executable: a test discovers real fixtures with a genuine dependency, arranges them four ways
(including both in one folder, and the dependency inverted in presentation), and asserts discovery
order, dependency declarations, and the module set come out byte-identical every time.
**No manifest change, and no version 5.** `manifest.name` has been required since version 1 and is
already the module's product identity, so the shell simply renders "Livestream" instead of
`livestream`. `suggestedGroup` was deliberately deferred rather than shipped: it would force a hard
manifest-version cutover for a *hint*, and its only correct semantics needs initialization logic that
would risk a module's suggestion overwriting an administrator's decision.
Two Core tables (migration `0003`) hold groups and placements — server-side, because a church
configures its Dashboard, not one browser; platform-global, because Ekklesia has no organization
model yet. `GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the
host-neutral module UI inventory rather than merged into it, embedding each generic descriptor
untouched. Reading needs the same permission the inventory does; organizing needs a new platform
permission, `core.dashboard.navigation.manage`. A newly installed module appears immediately as
ungrouped with no initialization write; an uninstalled module's placement is retained so reinstalling
restores its position; deleting a group ungroups its modules through the database rather than
application cleanup. See
[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md).
Phase 7A pass 1 gives the platform an **operational module lifecycle** — the smallest one that is
actually useful, and nothing beyond it. Until now the accepted module set was exactly "whatever was
found on disk at startup", and `ModuleLifecycleStage` named nine states that nothing produced or
consumed. That vocabulary is replaced by two axes that real code produces:
```text
configuration enabled / disabled persistent, administrator-owned
runtime running / disabled / blocked observed per boot
```
**Enablement is host configuration, and a module can never see it.** One Core table (migration
`0004`) holds `module_id` and `enabled` — no install source, download URL, update channel, signature,
or JSON blob, because those are the columns an installer would need and their absence is what stops
one being built by accident. A module never declares or reads its own enabled state, and is never
told it is enabled: its host loads and starts it, or does not. That is what keeps the same module
implementation runnable under a future standalone host.
**A newly discovered module is enabled, and the default is the absence of a row.** Deploying a module
is still sufficient to run it — no initialization write, no database edit — mirroring the rule that a
module with no Dashboard placement is still visible. An explicit record always wins, in both
directions, and a record for a module that is no longer deployed is retained and ignored, so
redeploying restores the decision.
**Required dependencies block; optional ones never do.** Disabling `storage` does not silently
re-enable it because something needs it — it makes every module that *requires* it ineligible,
transitively, with a structured reason (`required_dependency_disabled` →
`required_dependency_blocked`) naming the module an administrator must act on. A required *capability*
dependency blocks identically, because its only provider is a module. An optional dependency being
disabled is the same situation as it never being installed: the dependent starts, and
`capabilities.resolve(...)` returns `undefined`.
**Exposure follows eligibility, structurally.** `startApi` hands the same eligible list to the module
runtime and to the UI publication registry, so a disabled module is never imported at all: no hooks,
no routes (an unmounted path gets the ordinary `404` — there is no per-request enablement check
anywhere), no capabilities, no published artifact, and no entry in the browser inventory or Dashboard
navigation. Its **migrations are still discovered and applied**, because deployed schema ownership is
not runtime state, and its **permissions stay registered**, so grants survive an enable/disable cycle.
**Startup stays all-or-nothing, deliberately, and changes need a restart.** One module that fails to
start still takes API startup down; that guarantee is what ADR 0025/0026/0027 rest on, and inverting
it is a separate design. There is consequently no `failed` runtime state — no live process could
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable --
--module <id>` as the out-of-band path, since the HTTP API is served by the process a bad module
takes down. `PUT /api/v1/module-lifecycle/<id>/enabled` writes a decision and reports honestly that
the process has not changed:
```json
{ "configured": { "enabled": false, "explicit": true },
"runtime": { "state": "running" },
"restartRequired": true }
```
Nothing is hot-unloaded to make a button feel immediate. `running` means a module's `start` hook
completed and **nothing more** — a module that starts and then cannot reach its object storage has a
health problem, not a lifecycle problem, and this pass adds no health-probe contract. `pnpm doctor`
gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading
logs. Reading and changing both require the new restricted platform permission
`core.modules.lifecycle.manage`. Disabling a module is an **operational** control, not a sandbox: its
code is still on disk and still trusted. There is still **no installer**. See
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
Phase 7A pass 2 adds the **module configuration and secrets contract** — the thing standing between
Announcements and the module system, since that product currently has zero environment variables and
compiles a LAN IP address into its source.
A module declares what it needs configured in its manifest, and receives it as a frozen, validated
snapshot:
```json
"configuration": [
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 },
{ "key": "bucket", "type": "string", "required": true },
{ "key": "storageCredential", "type": "secret", "required": true }
]
```
```ts
start({ config }) {
const slideSeconds = config.getInteger("slideSeconds");
const credential = config.getSecret("storageCredential");
}
```
**Declaration is static, and that is the whole point.** The platform validates configuration
*before* deciding whether a module may start, so an enabled module missing a required value is
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
browser contributions.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment
(`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`) and never touches PostgreSQL. The module never sees
that variable name — it calls `config.getSecret("storageCredential")` — which is exactly what lets a
future standalone host supply the same declared secret from somewhere else. A `secret` declaration has
no `default` field, the write API refuses secret material outright, and the HTTP view has no `value`
field for one, so a secret is absent from responses *by construction* rather than filtered by a branch
somebody could delete.
Six types (`string`, `integer`, `boolean`, `enum`, `url`, `secret`), bounded constraints, no nesting,
no regular expressions — a validation vocabulary, not a form-builder DSL. Nothing is coerced:
`{"slideSeconds": "7"}` is a `400`, and a stored value that fails its own declared constraints blocks
the module rather than being quietly replaced by the default. Ordinary values live in one Core table
(migration `0005`), one row per `(module_id, key)`; a key the current manifest no longer declares is
retained and ignored so a downgrade never destroys an operator's value.
Changes take effect at the **next restart**, and `GET`/`PUT /api/v1/module-configuration/<id>` report
`restartRequired` rather than pretending otherwise — a sibling prefix, because `config` is a legal
module route segment. Both verbs need the new restricted `core.modules.configuration.manage`,
deliberately separate from the lifecycle permission: running a module and configuring it are different
authorities. A setting that must change *without* a restart is the module's own domain state — like
Announcements' `mode` toggle — not platform configuration.
**There is no settings UI**, and module UI receives no configuration at all in this phase. Endpoints
and credentials for shared infrastructure stay platform concerns: a module declares which *bucket* it
needs, not an S3 endpoint, leaving room for the object storage service Core still lacks. See
[ADR 0029](docs/architecture/decisions/0029-module-configuration-and-secrets.md).
Phase 7A pass 3 turns `manifest.storage.buckets` — declarable since manifest version 1 and inert ever
since — into a real **host-provided object-storage capability**. This is what stands between
Announcements and the module system: it currently fetches `http://10.40.0.2:9000/announcements`, parses
S3 XML by hand, and hands the browser direct S3 URLs.
A module declares a **logical** bucket and gets a scoped facade:
```json
"storage": { "buckets": [{ "id": "announcement-assets" }] }
```
```ts
const assets = context.storage.bucket("announcement-assets");
const object = await assets.get("slides/welcome.png");
return new Response(object.body, { headers: { "content-type": object.contentType } });
```
**The contract names no provider.** No endpoint, region, credential, or physical bucket is reachable
from it — not as a field, not as a type, not as a string. That is what lets the same module run under
Ekklesia and under a future standalone host backed by its own SeaweedFS, a shared one, MinIO, or AWS
S3. A module that configures its own S3 endpoint is a module welded to one deployment, which is the
mistake this pass exists to prevent.
Core's first provider speaks the S3-compatible protocol through the AWS SDK. SeaweedFS is the intended
target ([ADR 0004](docs/architecture/decisions/0004-replace-unmaintained-minio.md)) and already runs in
the development Compose stack — but it is a deployment choice, never an API name. Probing it directly
found a real divergence worth having caught: `CreateBucket` on an existing bucket reports
`BucketAlreadyExists`, **not** AWS's `BucketAlreadyOwnedByYou`, so treating only the AWS spelling as
success would have failed on every second boot. A live test against the real server pins it.
**Logical → physical mapping is deterministic and collision-safe.**
`ekklesia-announcements--announcement-assets-3f0c1d9a6b21`: an installation prefix (so several
deployments can share one storage service), the module and bucket ids joined by `--` — which cannot
occur inside either identifier, so `a-b`+`c` and `a`+`b-c` can never collide — and a digest over the
full untruncated identity. Two modules both declaring `assets` always get separate namespaces, and a
module can never see or choose the physical name.
**Declaring a bucket is now a real requirement.** With no provider configured, such a module is
reported `blocked · storage_unavailable` naming the logical bucket, and its required dependents block
behind it through the existing resolver — no second eligibility system. An installation with *no*
object storage remains a complete deployment; modules declaring no bucket are unaffected, which is why
the template now declares none.
**A storage outage is not a lifecycle event.** Eligibility asks whether storage is *configured*, never
whether it answered. Bucket creation is lazy on first use rather than at startup, so a storage service
being down can never stop the platform from booting — and a running module whose object store blinks
stays `running`, catches a structured error, and retries, exactly as Announcements does today. Nothing
ever deletes a bucket, on disable or otherwise.
Credentials are deployment-supplied (`S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
`S3_BUCKET_PREFIX`), never persisted, never routed through module configuration, and never visible to
modules, module UI, diagnostics, or any API response — errors are translated to a closed code set that
carries no endpoint, bucket, request id, or XML. This also retires the transitional `bucket`
*configuration* key ADR 0029 flagged: the manifest declaration is the requirement.
Multipart upload is deliberately deferred, and the provider is not yet "production-ready" by
ADR 0004's own bar. See
[ADR 0030](docs/architecture/decisions/0030-host-provided-module-object-storage.md).
Phase 7A pass 4 takes Ekklesia **out of the data path** for assets a browser is going to fetch
anyway. A module asks its logical bucket for a temporary browser URL and returns it; the browser
fetches the object from the storage service directly:
```ts
const { url, expiresAt } = await context.storage
.bucket("announcement-assets")
.createReadUrl("slides/welcome.png");
```
The contract still names no mechanism — no signing, no endpoint, no expiry format — so a host with no
S3 in it could satisfy the same method. The URL is **opaque** to module code and a **bearer
capability** to whoever holds it, so a module authorizes the request in its own route before asking
for one: storage bounds *reach*, never *identity*. Lifetimes run from one minute to six hours,
fifteen minutes by default, and out-of-range requests are refused rather than clamped. Modules
persist logical keys and generate URLs on demand; a stored URL would be a stored credential.
This is what makes media practical. A `Range` request against a delivery URL is answered by the
storage service directly — verified `206` with a correct `Content-Range` — so byte-range seeking needs
no proxy in Core. Signing is local, so a slideshow builds one URL per slide with no round trips, and
repeated calls inside a window return a byte-identical URL so browser caches and URL-keyed DOM
elements hold.
Because an S3-compatible signature covers the request's `Host`, a URL cannot be signed for one origin
and rewritten to another. The origin a browser uses is therefore a signing input
(`S3_DELIVERY_ENDPOINT`), defaulting to `S3_ENDPOINT` — so the standard deployment configures nothing
and delivery simply works. A reverse proxy in front of storage must pass the original `Host` through
unchanged; it may terminate TLS, because the scheme is not signed. Buckets stay private: an unsigned
request for the same object is refused, and a test asserts it. **Signed write and delete URLs are
deliberately deferred.** See
[ADR 0031](docs/architecture/decisions/0031-direct-object-delivery.md).
Phase 7A pass 5 adds native, host-neutral module realtime
([ADR 0032](docs/architecture/decisions/0032-native-module-realtime-transport.md)). During
`register`, a module declares a small, module-local channel and the permission that protects it;
during `start`, running work, and HTTP handlers, it publishes bounded JSON state through its own
scoped publisher. The publisher is inert by the time that module's stop hook begins. Core keeps only
the latest state and one pending update per subscriber. A module
must publish an initial state before startup completes, so every accepted subscriber receives one
atomic authoritative snapshot and then monotonically sequenced updates without a GET-then-subscribe
race. Slow clients never block publishers and intermediate states may be coalesced.
Ekklesia carries that contract over permission-gated Server-Sent Events, with cookie authentication,
15-second transport-owned keepalives, a five-minute reauthorization cycle, browser-native reconnect,
and a fresh snapshot on every reconnection. The module UI API remains transport-neutral:
```ts
const subscription = context.realtime.subscribe<SlideshowState>("state", {
onSnapshot: (state) => render(state),
onUpdate: (state) => render(state),
});
// Return this from `mount`, or call it from your unmount cleanup.
subscription.close();
```
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
pass through realtime unchanged.
Phase 7A pass 6 adds the smallest complete **authenticated display/kiosk host**
([ADR 0033](docs/architecture/decisions/0033-display-kiosk-host-contract.md)). Manifest version 6
adds an optional, independently declared display role:
```json
{
"entrypoints": {
"display": {
"artifact": "./dist/display/index.js",
"permission": "announcements.display.view"
}
}
}
```
The permission must be declared by that module and have an `authenticated` or `restricted` baseline. Primary UI and display UI are independently optional, must
use paths distinct from every other entrypoint, and use the same
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
`src/display/index.tsx`) and atomically emits every declared role.
Phase 7B pass 2 gives those screens an identity of their own
([ADR 0036](docs/architecture/decisions/0036-managed-device-identity-and-pairing.md)), so a
television no longer needs a person signed into it. Ekklesia now recognizes **three** principal
classes — anonymous, authenticated human, and managed device:
```text
screen opens /display/announcements → no identity → shows a 6-digit code
administrator types the code in the Dashboard, names the screen, approves
screen collects an HttpOnly credential → display and realtime work
administrator revokes → the screen loses access and shows a code again
```
**A device is not a user.** It has no email, no password, and no row in the identity tables, and it
satisfies the `authenticated` baseline *never* — so every route that settles for "somebody is signed
in" stays closed to a screen on a wall. Device authority runs through the same authorization
evaluator as everyone else's, and approval grants exactly the one display permission the host derived
from the module's own declaration. `/api/v1/principal` reports a device as anonymous on purpose: a
device cookie cannot open the Dashboard.
**The code is not the credential.** Six digits are printed across a television in a public hallway,
so they are treated as public: they identify a request to an administrator and yield nothing on their
own. Collecting the credential also requires an HttpOnly claim cookie only the requesting browser
holds, and approving requires the restricted `core.devices.manage` permission. The credential itself
is stored only as a SHA-256 digest, never logged, never in a URL, and never readable by the module
display code running in that same page.
Revocation is a server-side fact checked on every request, not a plea that a remote browser forget
something; re-pairing creates a **new** identity so an old credential can never come back. A revoked
screen fails its next request immediately, though an already-open realtime stream is bounded by the
existing five-minute forced reconnect rather than closed instantly. **QR scanning, per-device content
targeting, device groups, and mobile dashboards are deliberately deferred** — a future QR simply
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
identity at all.
Phase 7C pass 1 adds the platform's first **outbound email transport**
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
authenticate people and pair devices but could not send a single message, which is what blocked
email verification, password recovery, and invitations alike. Email is now a host-provided
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
this transactional message* and the provider owns the host, port, TLS, credentials, and every
transport error. **No provider's hostname appears anywhere in Core**, and a test asserts it —
Google Workspace SMTP Relay is the expected first deployment, not a platform dependency.
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
mail appears to come from. Control characters in addresses, subjects, and display names are refused
rather than stripped, which is what closes header injection.
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
relay authorized by source IP has neither — and the password is a deployment secret that never
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
single bounded attempt and deliberately not an exactly-once promise. There is **no
`POST /api/v1/send-email`** and no route that touches the provider.
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
while stating plainly that a successful connection does not establish deliverability. See
[Outbound email](docs/development/email.md).
Phase 7C pass 2 completes the human identity lifecycle over that transport
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
verified email address is a precondition for holding a session**: the identity system refuses an
unverified sign-in after checking the password and before creating one, so a correct password on an
unverified address carries exactly the authority of no password at all — which is why no
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
can steer where a recipient lands, and following a verification link deliberately mints **no
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
leaves managed devices untouched. Password recovery answers identically for an address with an
account, without one, already verified, or undeliverable — including when SMTP is broken, which is
why the delivery adapter never throws.
**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
can be signed in to before any relay exists. Normal production use of verification and recovery does
require configured outbound email. **Public signup remains disabled** — verification is not a licence
to self-register — and invitations and user administration are still not built. See
[Identity](docs/development/identity.md).
Ekklesia publishes the display bundle at
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
`GET /api/v1/module-displays/<module-id>`. The display surface is a separate full-viewport browser
application with no Dashboard chrome, no static module imports, and manual retry on failure. Only
eligible, running modules receive routes; manifest changes take effect on restart. Artifact bytes
remain anonymous trusted code, while the page and descriptor use normal cookie authentication and
the module-owned display permission. Realtime subscriptions and opaque signed object-delivery URLs
flow through the unchanged host context. Display inventory/management, variants and targeting,
public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the
Announcements migration, and Companion integration remain deferred.
Phase 7B pass 1 adds **host-owned browser authentication to the Dashboard shell**
([ADR 0034](docs/architecture/decisions/0034-host-owned-browser-authentication.md)). Anonymous
bootstrap renders an accessible email/password form; successful submission uses the existing
`POST /api/auth/sign-in/email` endpoint, whose successful browser-visible body is reduced to fixed
`{"ok":true}` while the HttpOnly cookie remains authoritative. The shell resolves
`GET /api/v1/principal` again before protected navigation and module UI, and sign-out uses the
existing `POST /api/auth/sign-out` before the same principal re-resolution. No token is stored or
passed to a module, no arbitrary callback/return URL exists, and leaving authenticated state
unmounts the active module through the generic loader's normal cleanup.
The flow intentionally offers neither signup nor password recovery: production signup remains
prohibited, no email sender exists, and the repository still lacks a supported way to create the
initial production identity required by platform-owner bootstrap. Authentication throttling is
enabled but in-memory and per API process. Host-only cookies, exact trusted origins, and Better
Auth's origin/CSRF checks remain unchanged; production Dashboard DNS/TLS/proxy/CSP and built-asset
hosting are deployment work, not guarantees added by this pass. Display device identity and the
public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
standalone host, and every real feature module also do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
declarations, typed facades, and frozen contexts are correctness boundaries, not a sandbox. Ekklesia
is also **not currently deployed**: there is no production instance, reverse proxy, public listener,
or deployment automation, so the origin/proxy/throttling properties a public launch requires are
unproven launch-blockers rather than existing guarantees. Read
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
module or exposing the API.
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
None of them is a feature module, and none of them names one.
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
badge, toast, and empty/error/loading states), every value a token reference, with real
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
exactly two shared roles to modules by name and pins both design packages beneath every other role.
The Dashboard and display install the defaults at their document root; custom properties inherit into
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
theme editor, brand builder, second theme, or component catalogue exists.**
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared
range; a contribution that fails is absent rather than broken. A consumer discovers through
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
database, storage, or credential — and loading one grants its module nothing.
Phase 8 pass 4 closes the object-storage write path
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
oversize is refused before authorization, the buffered path now counts what arrived rather than
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
every contributed renderer that display mounted, network-first so a screen can never be trapped on
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
stated rather than papered over — a display cannot render module content while offline, because
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
## Quick start ## Quick start
@@ -208,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.
@@ -234,15 +917,24 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
1 permission registered 6 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
DATABASE_URL is not configured.
Data definitions Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
2 migrations · checksums valid 7 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5
PostgreSQL PostgreSQL
✓ Database TLS verified ✓ Database TLS verified
@@ -256,10 +948,18 @@ Identity
○ Identity configuration skipped ○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification). Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set. Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Object storage Object storage
○ Check unavailable ○ Object storage not configured
Core storage integration has not been implemented yet. No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
✓ No module requires object storage
0 modules declare a logical storage bucket.
Outbound email
○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS Result: READY WITH WARNINGS
``` ```
@@ -283,6 +983,51 @@ CLI into a temporary location and structurally compares it against the checked-i
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
workflow. workflow.
`pnpm modules:enable -- --module <id>` and `pnpm modules:disable -- --module <id>` record which
installed modules this deployment runs. They read `DATABASE_URL`, run the same manifest-only
discovery every other command does (so a module this deployment cannot see is refused), write one
row, and start nothing — the change takes effect at the next `pnpm api:start`. They exist so a module
that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by
the process such a module takes down.
### Getting the first administrator into a fresh installation
A new installation has a login page and nothing to log in to: public registration is disabled by
default and cannot be enabled in production at all. One explicit, operator-invoked command creates
the initial identity and grants it platform ownership
([ADR 0035](docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
```bash
docker compose ... up -d # PostgreSQL and the rest of the stack
pnpm migrations:apply # the schema must already exist
pnpm identity:create-initial-owner -- --email [email protected]
pnpm api:start # then sign in at the Dashboard login page
```
It prompts for the password twice, invisibly, and never accepts one as a command-line argument —
`--password` is refused, because an argument is visible in shell history and in the process list.
For automated deployment it reads the password from standard input instead:
```bash
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
```
**No environment variable is read for the password and none belongs in `.env`**: it is used once to
create an account, not kept as deployment configuration. The command needs `DATABASE_URL` and
nothing else — no identity secret, no trusted origins, and no public base URL — so the first owner
can be created on a freshly provisioned server before TLS or DNS exist.
It runs **once**. Ownership bootstrap is single-use, so a second run is refused: it creates no
second owner, changes no existing password, and leaves the first owner untouched. It also never
applies migrations — an unmigrated database is reported, not silently fixed. If it created the
identity but failed before granting ownership, running the identical command again adopts that
identity and finishes, leaving its password alone. Creating *additional* users and owners belongs to
user administration, which does not exist yet.
This is not public signup and not a browser setup wizard: there is no `/signup`, `/register`, or
`/setup` route, no unauthenticated bootstrap endpoint, and no setup token. The login page stays
login-only.
`pnpm api:start` builds the workspace, then starts the identity HTTP transport `pnpm api:start` builds the workspace, then starts the identity HTTP transport
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated (`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for `DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
+6
View File
@@ -21,16 +21,22 @@
"dependencies": { "dependencies": {
"@ekklesia/contracts": "workspace:*", "@ekklesia/contracts": "workspace:*",
"@ekklesia/core": "workspace:*", "@ekklesia/core": "workspace:*",
"@ekklesia/shell": "workspace:*",
"@hono/node-server": "2.0.10", "@hono/node-server": "2.0.10",
"hono": "4.12.31" "hono": "4.12.31"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"better-auth": "1.6.23", "better-auth": "1.6.23",
"happy-dom": "20.0.11",
"pg": "8.22.0", "pg": "8.22.0",
"smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.10" "vitest": "4.1.10"
}, },
+305 -8
View File
@@ -1,6 +1,7 @@
import { import {
isAuthenticatedPrincipal, isAuthenticatedPrincipal,
type ModuleRuntimeServices, type ModuleRuntimeServices,
type ModuleSecretSource,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import type { import type {
@@ -8,13 +9,35 @@ import type {
AuthorizationStore, AuthorizationStore,
PermissionRegistry, PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { DashboardStore } from "@ekklesia/core/dashboard";
import type { DeviceStore } from "@ekklesia/core/devices";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import { Hono } from "hono"; import type {
DiscoveredModule,
ModuleConfigurationLookup,
ModuleConfigurationSettings,
ModuleConfigurationStore,
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
ModuleRealtimeRegistry,
ModuleStorageLookup,
ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules";
import type { Context, MiddlewareHandler } from "hono"; import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDeviceRoutes } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
import { createModuleUiRoutes } from "./module-ui-routes.js";
export interface CreateApiAppAuthorizationOptions { export interface CreateApiAppAuthorizationOptions {
readonly evaluator: AuthorizationEvaluator; readonly evaluator: AuthorizationEvaluator;
@@ -33,11 +56,87 @@ export interface CreateApiAppOptions {
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an * caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
* implicit fallback. */ * implicit fallback. */
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[]; readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry;
/** The same frozen services object the caller's started module runtime already passed to every /** The same frozen services object the caller's started module runtime already passed to every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh * module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one * object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
* `ModuleDatabase` for the lifetime of this API runtime. */ * `ModuleDatabase` for the lifetime of this API runtime. */
readonly moduleServices: ModuleRuntimeServices; readonly moduleServices: ModuleRuntimeServices;
/** The platform's module UI publication policy — always the one Core builds from the exact
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
* is: a caller that publishes no module UI must say so explicitly with an empty registry, never
* fall through to an implicit one. See `./module-ui-routes.js`. */
readonly moduleUi: ModuleUiPublicationRegistry;
/**
* Managed display devices. Optional so every existing composition — and every test that has no
* interest in devices — builds unchanged; when it is absent no pairing route is mounted, no
* device cookie is ever read, and a device principal simply cannot come into being.
*/
readonly devices?: {
readonly store: DeviceStore;
/** Whether the deployment is reached over https, so the device cookies can assert `Secure`
* without becoming unusable on a plaintext development origin. */
readonly secureCookies: boolean;
};
/** Surface-owned display host assets, read once by production startup. Optional only when the
* eligible publication registry contains no display entrypoint; route composition rejects an
* inconsistent display-bearing registry synchronously. */
readonly displayHostAssets?: DisplayHostAssets;
/**
* The platform's operational module lifecycle: the store holding which modules are enabled, and the
* boot snapshot describing what this process actually discovered, resolved, and started (ADR 0028).
* Required rather than defaulted, like every other module option here — a caller exercising no
* modules says so with an empty snapshot, never falls through to an implicit one.
*
* The snapshot must come from the same boot as `moduleRoutes` and `moduleUi`; that is what makes
* "running" an observation of this process rather than a prediction. See
* `./module-lifecycle-routes.js`.
*/
readonly moduleLifecycle: {
readonly snapshot: ModuleLifecycleSnapshot;
readonly store: ModuleLifecycleStore;
};
/**
* The platform's module configuration resource (ADR 0029): the discovered inventory whose
* manifests declare what may be configured, the settings this process resolved at boot (used only
* to compute `restartRequired`), the deployment's secret source, and the store.
*
* Required rather than defaulted, like every other module option here. `secrets` is explicit for
* the same reason: a caller that supplies no module secrets says so, and a module requiring one is
* then reported `secret_missing` rather than silently starting without it.
*/
readonly moduleConfiguration: {
readonly discoveredModules: readonly DiscoveredModule[];
readonly bootSettings: ModuleConfigurationSettings;
readonly secrets: ModuleSecretSource;
readonly store: ModuleConfigurationStore;
/** The per-module frozen snapshots the started runtime is actually using — the identical map
* `startDiscoveredModuleRuntime` received, so a module's HTTP handler and its lifecycle hooks
* read the same object rather than two independently resolved values. */
readonly snapshots: ModuleConfigurationLookup;
};
/**
* The platform's module object storage (ADR 0030): the per-module scoped facades the runtime
* started with, and whether a host provider is configured at all.
*
* Required rather than defaulted, like every other module option here — a caller exercising no
* storage says so with an empty map and `providerConfigured: false`, never falls through to an
* implicit one. Nothing here carries an endpoint, a region, or a credential: those never leave
* Core's provider.
*/
readonly moduleStorage: {
readonly snapshots: ModuleStorageLookup;
readonly providerConfigured: boolean;
};
/** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required
* rather than defaulted, like every other module option here: a caller that exercises no
* Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is
* presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore;
readonly runtime: EkklesiaIdentityRuntime; readonly runtime: EkklesiaIdentityRuntime;
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
@@ -52,6 +151,87 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
return c.json({ code }, status); return c.json({ code }, status);
} }
const EMAIL_SIGN_IN_PATH = "/api/auth/sign-in/email";
const EMAIL_SIGN_IN_SUCCESS_BODY = JSON.stringify({ ok: true });
/**
* Better Auth publishes general-purpose session-management endpoints even when an application has
* no client for them. These four can return the raw bearer-equivalent session token with the
* pinned configuration: get/list/update return a session record; change-password returns a newly
* minted token when asked to revoke other sessions. Ekklesia deliberately exposes only its narrow
* `/api/v1/principal` projection and has no native session/password-management browser UI, so the
* supported method/path pairs are closed at the outer HTTP boundary before Better Auth executes.
*/
const TOKEN_BEARING_AUTH_ENDPOINT_METHODS: Readonly<Record<string, readonly string[]>> =
Object.freeze({
"/api/auth/change-password": Object.freeze(["POST"]),
"/api/auth/get-session": Object.freeze(["GET", "POST"]),
"/api/auth/list-sessions": Object.freeze(["GET"]),
"/api/auth/update-session": Object.freeze(["POST"]),
});
function decodedRequestPathname(request: Request): string | undefined {
const encodedPathname = new URL(request.url).pathname;
try {
// URL.pathname intentionally preserves percent escapes. Decode exactly once before comparing,
// matching the maximum decoding a downstream HTTP router may safely perform; malformed
// encodings are denied rather than delegated through an ambiguous parser boundary.
return decodeURIComponent(encodedPathname);
} catch {
return undefined;
}
}
function isDisabledTokenBearingAuthRequest(request: Request): boolean {
const pathname = decodedRequestPathname(request);
if (pathname === undefined) {
return true;
}
// Better Auth is configured not to skip trailing slashes, but treating trailing-slash spellings
// as the same denied resource prevents a router-option change from opening a bypass. This does
// not collapse interior duplicate slashes or otherwise broaden path normalization.
const canonicalPath = pathname.replace(/\/+$/u, "") || "/";
return TOKEN_BEARING_AUTH_ENDPOINT_METHODS[canonicalPath]?.includes(request.method) ?? false;
}
/**
* Better Auth's native successful email sign-in response includes the newly created session token
* and the complete user object in its JSON body even though the browser already received the
* session through an HttpOnly cookie. The browser boundary must never make either value visible to
* application JavaScript. Keep this deliberately narrower than a general Better Auth response
* adapter: only the exact supported POST endpoint and a body-bearing successful response are
* normalized. Every sign-in failure and every other delegated auth endpoint remains Better Auth's
* native contract; the explicitly denied token-bearing endpoints above are never delegated.
*/
function browserSafeAuthResponse(request: Request, response: Response): Response {
const headers = new Headers(response.headers);
headers.set("Cache-Control", "no-store");
if (
request.method === "POST" &&
decodedRequestPathname(request) === EMAIL_SIGN_IN_PATH &&
response.ok &&
response.status !== 204 &&
response.status !== 205
) {
// The upstream length describes the deliberately discarded token/user JSON. Retaining it could
// truncate or stall the replacement payload once an HTTP adapter serializes this response.
headers.delete("Content-Length");
headers.set("Content-Type", "application/json; charset=UTF-8");
return new Response(EMAIL_SIGN_IN_SUCCESS_BODY, {
headers,
status: response.status,
statusText: response.statusText,
});
}
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
/** /**
* Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a * Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
* dynamic-function `origin` — including one that already has its own `Vary` (module responses * dynamic-function `origin` — including one that already has its own `Vary` (module responses
@@ -118,6 +298,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
app.use("/api/auth/*", corsMiddleware); app.use("/api/auth/*", corsMiddleware);
app.use("/api/v1/*", corsMiddleware); app.use("/api/v1/*", corsMiddleware);
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
// policy to the response.
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
return next();
}
c.header("Cache-Control", "no-store");
return c.json({ code: "NOT_FOUND" }, 404);
});
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES); const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => { app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
try { try {
@@ -126,13 +319,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which // intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
// only affects `c.res`) guarantees this regardless of how the handler produced its result, // only affects `c.res`) guarantees this regardless of how the handler produced its result,
// and never reads or buffers the body. // and never reads or buffers the body.
const headers = new Headers(response.headers); return browserSafeAuthResponse(c.req.raw, response);
headers.set("Cache-Control", "no-store");
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
} catch (error) { } catch (error) {
return sanitizedErrorResponse(c, error, 500); return sanitizedErrorResponse(c, error, 500);
} }
@@ -165,9 +352,119 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}), }),
); );
// A platform-owned route, mounted exactly like Core's own: it serves only artifacts the injected
// publication registry already approved, and it can never collide with a module's own routes,
// which all live under `/api/v1/modules/`. See `./module-ui-routes.js` for why it is a sibling of
// that prefix rather than nested inside it.
app.route("/", createModuleUiRoutes({ registry: options.moduleUi }));
// The browser host's bootstrap query, at the collection root of that same prefix. Unlike the
// artifact route above it is authenticated (`core.modules.ui.read`) and per-principal, because it
// is the module enumeration ADR 0025 deliberately withheld from the anonymous artifact route.
app.route(
"/",
createModuleUiInventoryRoutes({
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
}),
);
// A separate, chrome-free browser host. Exact page and descriptor routes are derived from the
// same eligible/running publication registry as artifact bytes; a display-only module never
// enters Dashboard navigation, and no request value can select a file or permission.
mountModuleDisplayRoutes(app, {
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
});
if (options.devices) {
// Host-owned device identity. Siblings of `/api/v1/modules/...`, reachable by no module, and
// mounted only when the deployment actually composed device support.
app.route(
"/",
createDeviceRoutes({
devices: options.devices.store,
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
secureCookies: options.devices.secureCookies,
trustedOrigins: allowedOrigins,
}),
);
}
// Layered deliberately on top of the generic inventory above rather than merged into it: the
// inventory stays the host-neutral "what is mountable" contract a standalone host could consume,
// and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`.
app.route(
"/",
createDashboardRoutes({
evaluator: options.authorization.evaluator,
moduleUi: options.moduleUi,
registry: options.authorization.registry,
runtime,
store: options.dashboard,
trustedOrigins: allowedOrigins,
}),
);
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
// rather than inside it. See `./module-lifecycle-routes.js`.
app.route(
"/",
createModuleLifecycleRoutes({
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
runtime,
configuration: {
secrets: options.moduleConfiguration.secrets,
store: options.moduleConfiguration.store,
},
storage: { providerConfigured: options.moduleStorage.providerConfigured },
snapshot: options.moduleLifecycle.snapshot,
store: options.moduleLifecycle.store,
trustedOrigins: allowedOrigins,
}),
);
// Same sibling-prefix rule as the lifecycle resource above: `/api/v1/modules/` belongs entirely
// to installed modules. See `./module-configuration-routes.js`.
app.route(
"/",
createModuleConfigurationRoutes({
bootSettings: options.moduleConfiguration.bootSettings,
discoveredModules: options.moduleConfiguration.discoveredModules,
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
runtime,
secrets: options.moduleConfiguration.secrets,
store: options.moduleConfiguration.store,
trustedOrigins: allowedOrigins,
}),
);
// A sibling platform-owned prefix, never nested under `/api/v1/modules/` where installed modules
// own every path. One exact route per runtime-published channel, authorized before subscription.
mountModuleRealtimeRoutes(app, {
evaluator: options.authorization.evaluator,
realtime: options.moduleRealtime,
registry: options.authorization.registry,
runtime,
});
mountModuleHttpRoutes(app, options.moduleRoutes, { mountModuleHttpRoutes(app, options.moduleRoutes, {
configurations: options.moduleConfiguration.snapshots,
storages: options.moduleStorage.snapshots,
evaluator: options.authorization.evaluator, evaluator: options.authorization.evaluator,
registry: options.authorization.registry, registry: options.authorization.registry,
realtime: options.moduleRealtime,
runtime, runtime,
services: options.moduleServices, services: options.moduleServices,
trustedOrigins: allowedOrigins, trustedOrigins: allowedOrigins,
+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;
};
} }
+51 -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,16 @@ import {
type IdentityConfig, type IdentityConfig,
type IdentityConfigInput, type IdentityConfigInput,
} from "@ekklesia/core/identity"; } from "@ekklesia/core/identity";
import {
createMailProviderConfig,
type MailProviderConfig,
type MailProviderConfigInput,
} from "@ekklesia/core/mail";
import {
createStorageProviderConfig,
type StorageProviderConfig,
type StorageProviderConfigInput,
} from "@ekklesia/core/storage";
import { import {
type ClientAddressConfig, type ClientAddressConfig,
type ClientAddressConfigInput, type ClientAddressConfigInput,
@@ -55,12 +65,34 @@ export interface ApiRuntimeConfig {
readonly host: string; readonly host: string;
readonly identity: IdentityConfig; readonly identity: IdentityConfig;
readonly port: number; readonly port: number;
/**
* The validated host object-storage provider, or `undefined` when this installation has none
* (ADR 0030).
*
* `undefined` is a complete, valid deployment: modules that declare no logical buckets are
* unaffected, and a module that does declare one is reported blocked rather than crashing
* anything. These values reach Core's provider and nowhere else — no module, module UI, browser
* bundle, diagnostic, or API response ever sees the endpoint or the credentials.
*/
readonly storage: StorageProviderConfig | undefined;
/**
* The validated host outbound-email provider, or `undefined` when this installation has none
* (ADR 0037).
*
* `undefined` is a valid deployment: the platform starts, every existing feature works, and the
* initial platform owner — created already verified by an operator command — can still sign in
* (ADR 0038). What it cannot do is verify a new account or deliver a password reset; each attempt
* reports a sanitized `MAIL_NOT_CONFIGURED` out of band rather than changing any HTTP response.
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
* API response ever sees the host or the password.
*/
readonly mail: MailProviderConfig | undefined;
} }
export interface ApiRuntimeConfigInput { export interface ApiRuntimeConfigInput {
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy /** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
* trust unless explicitly configured. See `./client-address.js`. */ * trust unless explicitly configured. See `./client-address.js`. */
readonly clientAddress?: ClientAddressConfigInput; readonly clientAddress?: ClientAddressConfigInput | undefined;
readonly databaseUrl?: unknown; readonly databaseUrl?: unknown;
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development; /** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */ * production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
@@ -68,6 +100,10 @@ export interface ApiRuntimeConfigInput {
readonly host?: unknown; readonly host?: unknown;
readonly identity: IdentityConfigInput; readonly identity: IdentityConfigInput;
readonly port?: unknown; readonly port?: unknown;
/** Optional. Omitted, or with no endpoint, means this installation has no object storage. */
readonly storage?: StorageProviderConfigInput;
/** Optional. Omitted, or with no host, means this installation sends no outbound email. */
readonly mail?: MailProviderConfigInput;
} }
export interface CreateApiRuntimeConfigOptions { export interface CreateApiRuntimeConfigOptions {
@@ -142,12 +178,25 @@ export function createApiRuntimeConfig(
// the URL carries TLS parameters that could override the policy. // the URL carries TLS parameters that could override the policy.
assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment); assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment);
// Validated in the same pass as every other runtime value, before `startApi` constructs a pool,
// a provider client, or a listener — so a malformed endpoint or a half-supplied credential pair
// fails startup with a sanitized code rather than surfacing later as a confusing storage error.
const storage = createStorageProviderConfig(input.storage, { environment });
// Validated in the same pass as everything else, so a malformed port, a half-supplied credential
// pair, or a production deployment that asked for plaintext SMTP fails startup with a sanitized
// code — rather than surfacing later as a confusing failure the first time something tries to
// send a password-reset email.
const mail = createMailProviderConfig(input.mail, { environment });
return Object.freeze({ return Object.freeze({
clientAddress, clientAddress,
databaseTransport, databaseTransport,
databaseUrl, databaseUrl,
host, host,
identity, identity,
mail,
port, port,
storage,
}); });
} }
+310
View File
@@ -0,0 +1,310 @@
import {
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_UI_READ,
} from "@ekklesia/core/authorization";
import {
DASHBOARD_SORT_ORDER_MAX,
DASHBOARD_SORT_ORDER_MIN,
type DashboardStore,
DashboardStoreError,
isValidDashboardIdentifier,
normalizeDashboardGroupLabel,
resolveDashboardNavigation,
} from "@ekklesia/core/dashboard";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js";
export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`;
const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`;
/**
* One module as the Dashboard presents it: the module's display name, plus the generic mount
* descriptor unchanged.
*
* The descriptor is **embedded rather than flattened**, and that is the layering made visible.
* `module` is exactly `HostModuleUiDescriptor` — the host-neutral answer to "what is mountable and
* how" (ADR 0026) — and `name` is the Dashboard's presentation concern layered on top. A standalone
* host consumes the inner object and never learns that Ekklesia has folders; Ekklesia's shell
* consumes the outer one. Neither contract is warped to serve the other, and there is one
* construction of the descriptor rather than two that could drift.
*/
export interface DashboardNavigationModuleView {
readonly name: string;
readonly module: HostModuleUiDescriptor;
}
export interface DashboardNavigationGroupView {
/** The stable key, unchanged by a rename. Addresses the group in the mutation API; never displayed. */
readonly id: string;
readonly label: string;
readonly order: number;
readonly modules: readonly DashboardNavigationModuleView[];
}
export interface DashboardNavigationResponse {
readonly groups: readonly DashboardNavigationGroupView[];
/** Modules an administrator placed outside every group, and modules never configured at all — a
* newly installed module most importantly. Never empty merely because nothing has been arranged. */
readonly ungrouped: readonly DashboardNavigationModuleView[];
}
export interface CreateDashboardRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same publication registry the artifact route and the generic inventory serve from: the one
* accepted, mountable module set. Navigation never rescans, never reads a manifest, and never
* discovers anything of its own. */
readonly moduleUi: ModuleUiPublicationRegistry;
readonly store: DashboardStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. A
* validation failure the route already rejects can still arrive here from a concurrent change, so
* both paths are handled rather than assumed unreachable. */
function storeFailure(c: Context, error: unknown): Response {
if (error instanceof DashboardStoreError) {
if (error.code === "DASHBOARD_GROUP_NOT_FOUND") {
return sanitizedFailure(c, 404, error.code);
}
if (error.code === "DASHBOARD_READ_FAILED" || error.code === "DASHBOARD_WRITE_FAILED") {
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(c, 503, "DASHBOARD_WRITE_FAILED");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parsedSortOrder(value: unknown): number | undefined {
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value < DASHBOARD_SORT_ORDER_MIN ||
value > DASHBOARD_SORT_ORDER_MAX
) {
return undefined;
}
return value;
}
const GROUP_BODY_KEYS = new Set(["label", "order"]);
const PLACEMENT_BODY_KEYS = new Set(["groupId", "order"]);
/**
* Structured, closed validation — never a free-form configuration blob. Any key beyond the declared
* ones makes the whole body invalid rather than being silently dropped, so a caller cannot smuggle a
* field a future version might interpret, and a typo fails loudly instead of quietly doing nothing.
*/
function parseGroupBody(raw: unknown): { label: string; order: number } | undefined {
if (!isRecord(raw) || Object.keys(raw).some((key) => !GROUP_BODY_KEYS.has(key))) {
return undefined;
}
const order = parsedSortOrder(raw.order);
const label = normalizeDashboardGroupLabel(raw.label);
if (label === undefined || order === undefined) {
return undefined;
}
return { label, order };
}
function parsePlacementBody(raw: unknown): { groupId: string | null; order: number } | undefined {
if (!isRecord(raw) || Object.keys(raw).some((key) => !PLACEMENT_BODY_KEYS.has(key))) {
return undefined;
}
const order = parsedSortOrder(raw.order);
const groupId = raw.groupId;
if (order === undefined || (groupId !== null && !isValidDashboardIdentifier(groupId))) {
return undefined;
}
return { groupId, order };
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
/**
* The Ekklesia Dashboard's navigation: how this installation presents its mountable modules, and the
* narrow, structured API for changing that.
*
* ## Navigation is layered on the inventory, not merged into it
*
* `GET /api/v1/module-ui` (ADR 0026) answers *what may be mounted* and stays host-neutral — a
* standalone host uses it and never learns Ekklesia has folders. This route answers *how this
* Dashboard presents those modules*, and embeds each generic descriptor untouched inside a
* presentation wrapper. Keeping them separate is what stops Dashboard semantics leaking into the
* portable module-host contract.
*
* ## Grouping is presentation, and structurally cannot be anything else
*
* The resolved view is computed by `resolveDashboardNavigation`, a pure function of stored
* configuration and the current module set. It reads no manifest, no dependency, no capability, and
* no entrypoint, and it produces no relationship between two modules. Modules stay flat (ADR 0021,
* ADR 0027): two modules in one group are exactly as related as two in different groups, and moving
* one changes a single presentation row — never its artifact, its API root, its permissions, its
* dependencies, or when it starts.
*
* ## Reading versus organizing
*
* Reading requires `core.modules.ui.read`, the same permission the generic inventory requires,
* because this response *contains* that inventory and serves the same audience: any signed-in user
* needs it to see anything at all. Organizing requires `core.dashboard.navigation.manage`
* (restricted) — a platform permission, never a module's — and, like every other Core mutation, an
* exact trusted `Origin` and a bounded body before authorization work begins.
*
* Holding the manage permission grants no module authority. Every module route remains enforced
* against its own declared permission, and no arrangement of folders changes a single authorization
* decision.
*/
export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Hono {
const routes = new Hono();
const middlewareOptions: AuthorizationMiddlewareOptions = {
evaluator: options.evaluator,
registry: options.registry,
runtime: options.runtime,
};
const requireRead = requirePermission(middlewareOptions, CORE_MODULES_UI_READ);
const requireManage = requirePermission(middlewareOptions, CORE_DASHBOARD_NAVIGATION_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
routes.get(DASHBOARD_NAVIGATION_PATH, requireRead, async (c) => {
c.header("Cache-Control", "no-store");
// Per-principal: the embedded descriptors carry permission hints evaluated for this caller.
c.header("Vary", "Origin, Cookie");
const principal = getAuthorizedPrincipal(c);
try {
const [configuration, descriptors] = await Promise.all([
options.store.readConfiguration(),
describeMountableModules(options.evaluator, principal, options.moduleUi),
]);
const view = resolveDashboardNavigation(
configuration,
descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })),
);
const present = (entry: {
readonly descriptor: HostModuleUiDescriptor;
readonly name: string;
}): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name });
return c.json(
{
groups: view.groups.map((group) => ({
id: group.id,
label: group.label,
modules: group.modules.map(present),
order: group.order,
})),
ungrouped: view.ungrouped.map(present),
} satisfies DashboardNavigationResponse,
200,
);
} catch (error) {
if (error instanceof DashboardStoreError) {
return storeFailure(c, error);
}
// An authorization-evaluation outage, exactly as the generic inventory reports it: a
// permission hint that could not be determined is never rendered as "not granted".
return sanitizedFailure(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
}
});
routes.put(
`${DASHBOARD_GROUPS_PATH}/:groupId`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const groupId = c.req.param("groupId");
const body = parseGroupBody(await readJsonBody(c));
if (!body || !isValidDashboardIdentifier(groupId)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
// Idempotent by design: creating and renaming are the same operation against a
// client-chosen, stable id, so a rename can never orphan the placements pointing at it.
const group = await options.store.upsertGroup({
id: groupId,
label: body.label,
sortOrder: body.order,
});
return c.json({ id: group.id, label: group.label, order: group.sortOrder }, 200);
} catch (error) {
return storeFailure(c, error);
}
},
);
routes.delete(`${DASHBOARD_GROUPS_PATH}/:groupId`, requireOrigin, requireManage, async (c) => {
c.header("Cache-Control", "no-store");
const groupId = c.req.param("groupId");
if (!isValidDashboardIdentifier(groupId)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
// Modules in the group become ungrouped through the schema's `on delete set null`, so no
// module is lost and no placement is left pointing at a group that no longer exists.
await options.store.deleteGroup(groupId);
return c.body(null, 204);
} catch (error) {
return storeFailure(c, error);
}
});
routes.put(
`${DASHBOARD_PLACEMENTS_PATH}/:moduleId`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const moduleId = c.req.param("moduleId");
const body = parsePlacementBody(await readJsonBody(c));
if (!body || !isValidDashboardIdentifier(moduleId)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
// A placement may be stored for a module that is not installed: that is how uninstalling
// and reinstalling preserves an arrangement. Nothing renders it until the module is back.
const placement = await options.store.setPlacement({
groupId: body.groupId,
moduleId,
sortOrder: body.order,
});
return c.json(
{
groupId: placement.groupId,
moduleId: placement.moduleId,
order: placement.sortOrder,
},
200,
);
} catch (error) {
return storeFailure(c, error);
}
},
);
return routes;
}
+25 -4
View File
@@ -3,14 +3,29 @@ import {
type AuthorizationDrizzleDatabase, type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView, createAuthorizationDrizzleView,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import {
createDashboardDrizzleView,
type DashboardDrizzleDatabase,
} from "@ekklesia/core/dashboard";
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database"; import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/core/devices";
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
import { createModuleDatabaseView } from "@ekklesia/core/modules"; import {
createModuleConfigurationView,
createModuleDatabaseView,
createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase,
type ModuleLifecycleDrizzleDatabase,
} from "@ekklesia/core/modules";
export interface ApiDatabaseHandle { export interface ApiDatabaseHandle {
readonly authorization: AuthorizationDrizzleDatabase; readonly authorization: AuthorizationDrizzleDatabase;
readonly devices: DeviceDrizzleDatabase;
readonly dashboard: DashboardDrizzleDatabase;
readonly identity: IdentityDrizzleDatabase; readonly identity: IdentityDrizzleDatabase;
readonly module: ModuleDatabase; readonly module: ModuleDatabase;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
/** Closes the one shared pool exactly once, regardless of how many typed views were created /** Closes the one shared pool exactly once, regardless of how many typed views were created
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */ * over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
readonly close: () => Promise<void>; readonly close: () => Promise<void>;
@@ -19,9 +34,11 @@ export interface ApiDatabaseHandle {
/** /**
* Composes the one physical PostgreSQL pool this process uses for Core's identity and * Composes the one physical PostgreSQL pool this process uses for Core's identity and
* authorization schemas plus the module database facade: one validated, timeout-bounded * authorization schemas plus the module database facade: one validated, timeout-bounded
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three * `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`, * typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()` * `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleConfigurationView`,
* `createModuleDatabaseView`). No domain
* owns or closes the pool in this composition — `close()`
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/ * here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable * `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
* for callers that only need one domain. * for callers that only need one domain.
@@ -35,7 +52,11 @@ export function createApiDatabase(
return Object.freeze({ return Object.freeze({
authorization: createAuthorizationDrizzleView(pool), authorization: createAuthorizationDrizzleView(pool),
close, close,
devices: createDeviceDrizzleView(pool),
dashboard: createDashboardDrizzleView(pool),
identity: createIdentityDrizzleView(pool), identity: createIdentityDrizzleView(pool),
module: createModuleDatabaseView(pool), module: createModuleDatabaseView(pool),
moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool),
}); });
} }
+94
View File
@@ -0,0 +1,94 @@
import { createDevicePrincipal, type EkklesiaPrincipal } from "@ekklesia/contracts";
import type { DeviceStore } from "@ekklesia/core/devices";
import { DEVICE_LAST_SEEN_THROTTLE_MS } from "@ekklesia/core/devices";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { DEVICE_SESSION_COOKIE } from "./device-routes.js";
/**
* Extends principal resolution with managed devices, without touching how a *person* is resolved
* (ADR 0036).
*
* Composition rather than modification is deliberate. Core's identity runtime is the Better Auth
* boundary and knows only about human sessions; teaching it about devices would put two unrelated
* credential systems behind one implementation and make "which one answered?" an internal detail.
* Wrapping keeps Better Auth's behaviour byte-for-byte unchanged — including that an infrastructure
* failure still throws rather than degrading to anonymous — and adds exactly one fallback.
*
* **A human session always wins.** If Better Auth resolves a person, that is the answer and the
* device cookie is never even read. This matters because a browser can hold both: an administrator
* who opens a display page on their own laptop after pairing it stays themself, and no device
* cookie can dilute or replace a human identity.
*
* Because every gate in the platform — the display page, the descriptor, module HTTP routes,
* realtime — resolves principals through this one function, a device becomes a principal in exactly
* one place, and every authorization decision about it is still made by the ordinary evaluator.
*/
export interface DevicePrincipalOptions {
readonly runtime: EkklesiaIdentityRuntime;
readonly devices: DeviceStore;
readonly now?: () => Date;
}
/** RFC 6265 `cookie-string` parsing, matching how Core's own session-cookie detection reads a
* header: split on `;`, take the text before the first `=` as the name, compare whole names
* exactly. Never a substring test, so `x_ekklesia_device_session` is correctly not a match. */
function readCookie(headers: Headers, name: string): string | undefined {
const header = headers.get("cookie");
if (header === null) {
return undefined;
}
for (const pair of header.split(";")) {
const separator = pair.indexOf("=");
if (separator === -1) {
continue;
}
if (pair.slice(0, separator).trim() === name) {
const value = pair.slice(separator + 1).trim();
return value.length === 0 ? undefined : value;
}
}
return undefined;
}
export function withDevicePrincipals(options: DevicePrincipalOptions): EkklesiaIdentityRuntime {
const now = options.now ?? ((): Date => new Date());
return Object.freeze({
handleAuthRequest: (request: Request): Promise<Response> =>
options.runtime.handleAuthRequest(request),
async resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal> {
// Unchanged, including its failure behaviour: a genuine outage still throws rather than
// silently letting a device credential answer a question about a person.
const human = await options.runtime.resolvePrincipal(headers);
if (human.kind !== "anonymous") {
return human;
}
const credential = readCookie(headers, DEVICE_SESSION_COOKIE);
if (credential === undefined) {
return human;
}
const at = now();
const device = await options.devices.resolveCredential(credential, at);
if (!device) {
// A revoked, expired, or simply wrong credential is anonymous — never an error the caller
// could use to tell those three apart.
return human;
}
// Throttled here as well as in SQL: the common case must not even issue a statement, or a
// display polling realtime would turn an operational nicety into continuous write traffic.
const lastSeen = device.lastSeenAt;
if (
lastSeen === undefined ||
at.getTime() - lastSeen.getTime() >= DEVICE_LAST_SEEN_THROTTLE_MS
) {
await options.devices.touchLastSeen(device.deviceId, at).catch(() => undefined);
}
return createDevicePrincipal(device.deviceId);
},
});
}
+357
View File
@@ -0,0 +1,357 @@
import { safeParseEkklesiaDeviceId } from "@ekklesia/contracts";
import { CORE_DEVICES_MANAGE } from "@ekklesia/core/authorization";
import type { DeviceStore } from "@ekklesia/core/devices";
import { DEVICE_CREDENTIAL_TTL_MS } from "@ekklesia/core/devices";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import { Hono as HonoApp } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
/**
* Host-owned device identity transport: pairing a display, and managing what has been paired
* (ADR 0036).
*
* Both prefixes are siblings of `/api/v1/modules/...` and cannot collide with any module route.
* Nothing here is reachable by module code, and no module-facing contract mentions any of it — a
* module receives a principal and its permissions, and never learns that pairing exists.
*/
export const DEVICE_PAIRING_PREFIX = "/api/v1/device-pairing";
export const DEVICES_PREFIX = "/api/v1/devices";
/**
* The device's two cookies, deliberately named nothing like Better Auth's session cookie.
*
* Distinct names are what keep the two identity systems from being confused for one another: the
* human resolver looks only for Better Auth's cookie and the device resolver looks only for these,
* so neither can ever be satisfied by the other's credential no matter what a caller sends.
*/
export const DEVICE_PAIRING_COOKIE = "ekklesia_device_pairing";
export const DEVICE_SESSION_COOKIE = "ekklesia_device_session";
/** How often an unpaired screen should ask again. Slow enough that a waiting display is not a load
* source, fast enough that someone standing at the screen sees it come alive. */
export const DEVICE_PAIRING_POLL_INTERVAL_MS = 3_000;
export interface DeviceRoutesOptions extends AuthorizationMiddlewareOptions {
readonly devices: DeviceStore;
/** The same eligible/running publication registry the display routes use. It is the only source
* of the permission a pairing request may ask for. */
readonly moduleUi: ModuleUiPublicationRegistry;
readonly trustedOrigins: ReadonlySet<string>;
/** True when the platform is reached over https, which is what `Secure` requires to be usable at
* all. Derived from the validated identity base URL, never from a request. */
readonly secureCookies: boolean;
readonly now?: () => Date;
}
function noStore(c: Context): void {
c.header("Cache-Control", "no-store");
}
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
noStore(c);
return c.json({ code }, status);
}
async function readJsonObject(c: Context): Promise<Record<string, unknown> | undefined> {
try {
const body: unknown = await c.req.json();
return typeof body === "object" && body !== null && !Array.isArray(body)
? (body as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
function readString(body: Record<string, unknown>, key: string, max: number): string | undefined {
const value = body[key];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 || trimmed.length > max ? undefined : trimmed;
}
/**
* Writes one of the device cookies.
*
* `HttpOnly` so no script — least of all a module's display bundle, which runs in this very page —
* can read the value; `SameSite=Strict` because a display only ever talks to the host that serves
* it and nothing legitimate arrives cross-site; host-only (no `Domain`) so a sibling hostname
* cannot claim it; `Path=/` because the credential is presented to the descriptor, the module API,
* and realtime alike. `Secure` follows the deployment's own scheme — asserting it on a plaintext
* development origin would make the cookie silently unusable rather than more secure.
*/
function writeDeviceCookie(
c: Context,
options: DeviceRoutesOptions,
name: string,
value: string,
maxAgeSeconds: number,
): void {
setCookie(c, name, value, {
httpOnly: true,
maxAge: maxAgeSeconds,
path: "/",
sameSite: "Strict",
secure: options.secureCookies,
});
}
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date());
const requireManage = requirePermission(options, CORE_DEVICES_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
/**
* An unpaired display asks to be paired.
*
* The **only** thing the caller supplies is which module's display it is sitting on, and even
* that is not taken at its word: the module is looked up in the publication registry and the
* permission is read from that module's validated display declaration. A browser therefore cannot
* ask to be granted `core.devices.manage`, another module's permission, or anything else — the
* request records the permission the host derived, and approval grants exactly that.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/requests`,
requireOrigin,
mutationBodyLimit,
async (c: Context) => {
const body = await readJsonObject(c);
const moduleId = body === undefined ? undefined : readString(body, "moduleId", 128);
if (moduleId === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const display = options.moduleUi
.listDisplays()
.find((candidate) => candidate.moduleId === moduleId);
if (!display) {
// Unknown, dashboard-only, disabled, or dependency-blocked all answer the same way, so
// pairing never becomes a way to enumerate what is installed.
return sanitizedFailure(c, 404, "NOT_FOUND");
}
try {
const at = now();
const created = await options.devices.createPairingRequest({
moduleId: display.moduleId,
now: at,
permissionId: display.displayPermission,
});
writeDeviceCookie(
c,
options,
DEVICE_PAIRING_COOKIE,
created.claimSecret,
Math.max(1, Math.ceil((created.expiresAt.getTime() - at.getTime()) / 1000)),
);
noStore(c);
// The code is returned because it has to be shown on the screen. The claim secret is not:
// it goes only into the HttpOnly cookie above, so reading the code off a television tells
// an onlooker nothing they could use.
return c.json(
{
code: created.code,
expiresAt: created.expiresAt.toISOString(),
pollIntervalMs: DEVICE_PAIRING_POLL_INTERVAL_MS,
},
201,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/**
* The display asks whether it has been approved yet, and collects its credential when it has.
*
* Authority here is the claim cookie and nothing else. The pairing code is not accepted — it is
* public, printed across a screen, and a flow that issued a credential to whoever presented it
* would hand the device's identity to anyone who walked past. Consumption is single-use inside
* the store, so a replayed or concurrent claim never yields a second credential.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/claims`,
requireOrigin,
mutationBodyLimit,
async (c: Context) => {
const claimSecret = getCookie(c, DEVICE_PAIRING_COOKIE);
if (claimSecret === undefined || claimSecret.length === 0) {
return sanitizedFailure(c, 400, "DEVICE_PAIRING_UNKNOWN");
}
try {
const at = now();
const result = await options.devices.claimPairingRequest({ claimSecret, now: at });
noStore(c);
if (result.paired) {
writeDeviceCookie(
c,
options,
DEVICE_SESSION_COOKIE,
result.paired.credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
// The pairing cookie has done its job; leaving it would keep a spent secret on the device.
writeDeviceCookie(c, options, DEVICE_PAIRING_COOKIE, "", 0);
// Deliberately no credential, device id, or permission in the body: the browser needs to
// know only that it may now load the display, and everything it needs travels in the
// Set-Cookie header where script cannot reach it.
return c.json({ status: "paired" as const }, 200);
}
return c.json({ status: result.status }, 200);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/**
* An administrator types the code from the screen and sees what it is asking for, before deciding.
*
* The code travels in a request body rather than a path or query so it does not land in access
* logs, browser history, or a `Referer`. Reaching this at all requires `core.devices.manage`, so
* it is not an enumeration oracle for anyone who is not already trusted to approve pairings.
*/
routes.post(
`${DEVICE_PAIRING_PREFIX}/lookups`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const body = await readJsonObject(c);
const code = body === undefined ? undefined : readString(body, "code", 16);
if (code === undefined || !/^[0-9]{6}$/u.test(code)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
const found = await options.devices.findPendingByCode(code, now());
if (!found) {
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
}
noStore(c);
return c.json(
{
expiresAt: found.expiresAt.toISOString(),
moduleId: found.moduleId,
permissionId: found.permissionId,
requestedAt: found.createdAt.toISOString(),
},
200,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
/** Approval. The administrator names the device; the unpaired screen never names itself. */
routes.post(
`${DEVICE_PAIRING_PREFIX}/approvals`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const body = await readJsonObject(c);
const code = body === undefined ? undefined : readString(body, "code", 16);
const deviceName = body === undefined ? undefined : readString(body, "deviceName", 128);
if (code === undefined || !/^[0-9]{6}$/u.test(code) || deviceName === undefined) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const principal = getAuthorizedPrincipal(c);
if (principal.kind !== "authenticated") {
// Unreachable through `requireManage`, which only ever admits a person. Stated rather than
// assumed, so a device could never be recorded as having approved its own pairing.
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
const approved = await options.devices.approvePairingRequest({
approvedBy: principal.userId,
code,
deviceName,
now: now(),
});
if (!approved) {
// Expired, already approved, already consumed, or never existed. One answer for all of
// them: which one it was is not information this endpoint owes anybody.
return sanitizedFailure(c, 404, "DEVICE_PAIRING_UNKNOWN");
}
noStore(c);
return c.json({ moduleId: approved.moduleId, permissionId: approved.permissionId }, 200);
} catch {
return sanitizedFailure(c, 503, "DEVICE_PAIRING_UNAVAILABLE");
}
},
);
routes.get(DEVICES_PREFIX, requireManage, async (c: Context) => {
try {
const devices = await options.devices.listDevices();
noStore(c);
return c.json(
{
devices: devices.map((device) => ({
createdAt: device.createdAt.toISOString(),
id: device.id,
name: device.name,
permissions: device.permissions,
revoked: device.revokedAt !== undefined,
...(device.lastSeenAt === undefined
? {}
: { lastSeenAt: device.lastSeenAt.toISOString() }),
...(device.revokedAt === undefined
? {}
: { revokedAt: device.revokedAt.toISOString() }),
})),
},
200,
);
} catch {
return sanitizedFailure(c, 503, "DEVICE_LOOKUP_UNAVAILABLE");
}
});
/**
* Revocation, which is a server-side fact rather than a request that the remote browser discard
* anything. The device's own copy of its credential keeps existing and stops working.
*/
routes.post(
`${DEVICES_PREFIX}/:deviceId/revoke`,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c: Context) => {
const parsed = safeParseEkklesiaDeviceId(c.req.param("deviceId"));
if (!parsed.success) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const principal = getAuthorizedPrincipal(c);
if (principal.kind !== "authenticated") {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
await options.devices.revokeDevice({
deviceId: parsed.data,
now: now(),
revokedBy: principal.userId,
});
} catch {
return sanitizedFailure(c, 404, "NOT_FOUND");
}
noStore(c);
return c.body(null, 204);
},
);
return routes;
}
+73
View File
@@ -0,0 +1,73 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
/**
* The three immutable, platform-owned files that make up Ekklesia's display browser host.
*
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
* surface package. Request input is never involved in this read and the package is never exposed as
* a static directory. Keeping text rather than a mutable `Buffer` also makes the frozen snapshot a
* genuinely immutable value that every response can reuse safely.
*/
export interface DisplayHostAssets {
readonly html: string;
readonly script: string;
readonly scriptEtag: string;
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
* at a path whose directory is its scope so it controls display pages and nothing else. */
readonly serviceWorker: string;
readonly serviceWorkerEtag: string;
}
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
* location, and machine path are intentionally not retained or surfaced. */
export class DisplayHostAssetError extends Error {
readonly code = "DISPLAY_HOST_ASSET_UNAVAILABLE" as const;
constructor() {
super("The display host browser assets are unavailable.");
this.name = "DisplayHostAssetError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Captures one immutable, internally consistent display-host asset snapshot.
*
* The strong validator is always derived from the exact script text in the same snapshot; callers
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
* failed host build rather than a valid page that can never mount anything.
*/
export function createDisplayHostAssets(
html: string,
script: string,
serviceWorker: string,
): DisplayHostAssets {
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
throw new DisplayHostAssetError();
}
return Object.freeze({
html,
script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
});
}
/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
* than leaving a display route that can authorize successfully and then return a broken shell. */
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
try {
// Sequential deliberately: should both files be absent, diagnostics stay deterministic and no
// rejected sibling promise is left running after this function has already failed.
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
return createDisplayHostAssets(html, script, serviceWorker);
} catch {
throw new DisplayHostAssetError();
}
}
+375
View File
@@ -0,0 +1,375 @@
import {
type ModuleConfigurationDeclaration,
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
moduleConfigurationKeySchema,
moduleIdSchema,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import { CORE_MODULES_CONFIGURATION_MANAGE } from "@ekklesia/core/authorization";
import {
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
resolveModuleConfigurationFor,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
/**
* A **sibling** of `/api/v1/modules/`, never a child — the third application of the rule ADR 0025
* established and ADR 0028 reapplied. `config` and `configuration` are both ordinary lowercase
* static segments under `moduleRoutePathSchema`, so a module named `announcements` may legitimately
* register `PUT /config` and land on exactly `/api/v1/modules/announcements/config`. Publishing the
* platform's own configuration mutation there would put Core and an installed module in one URL
* namespace with registration order deciding which one answers — for a route that changes how
* modules behave.
*/
export const MODULE_CONFIGURATION_PATH = "/api/v1/module-configuration";
const MODULE_CONFIGURATION_MODULE_PATH = `${MODULE_CONFIGURATION_PATH}/:moduleId`;
/**
* One declared key as an operator sees it: the module's own declaration, plus what is currently
* stored, plus what the running process actually used.
*
* `value` is **absent for every `secret` key, always**. Not null, not redacted, not a placeholder —
* absent, because there is no code path that could put secret material here: Ekklesia never persists
* it, and the resolver's snapshot is never serialized. `configured` reports whether the deployment
* supplied one, which is the only thing an operator needs to know from here.
*/
export interface ModuleConfigurationKeyView {
readonly key: string;
readonly type: ModuleConfigurationDeclaration["type"];
readonly required: boolean;
readonly description?: string;
/** The declared default, for non-secret keys that have one. Part of the module's public manifest. */
readonly default?: string | number | boolean;
/** Present only for `type: "enum"`. */
readonly values?: readonly string[];
/** The stored value, when one is stored and the key is not a secret. Absent means "no value
* stored" — the default applies, or the key is unset. */
readonly value?: string;
/** For a secret: whether the deployment supplies it. For anything else: whether a value is stored.
* Never implies the value is valid — see the module's `runtime` entry for that. */
readonly configured: boolean;
}
export interface ModuleConfigurationView {
readonly moduleId: string;
readonly version: string;
readonly keys: readonly ModuleConfigurationKeyView[];
/** Stored keys this module's current manifest no longer declares. Retained rather than deleted, so
* a downgrade or a rename does not destroy an operator's value, and surfaced here so it is visible
* rather than merely tolerated. */
readonly unknownKeys: readonly string[];
/** `true` when the stored configuration no longer matches what this process resolved at boot. A
* configuration change is applied by the next restart; nothing is hot-reloaded (ADR 0029). */
readonly restartRequired: boolean;
}
export interface CreateModuleConfigurationRoutesOptions extends AuthorizationMiddlewareOptions {
/** The discovered inventory this process booted from — the source of every declaration this route
* validates against. Never re-discovered: a manifest that changed on disk since boot is not what
* the running platform is using. */
readonly discoveredModules: readonly DiscoveredModule[];
/** The stored settings this process resolved at boot, used only to compute `restartRequired` by
* comparison with what is stored now. */
readonly bootSettings: ModuleConfigurationSettings;
readonly secrets: ModuleSecretSource;
readonly store: ModuleConfigurationStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. The store's
* errors never carry a value, so nothing here can leak one either. */
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
if (error instanceof ModuleConfigurationStoreError) {
if (
error.code === "MODULE_CONFIGURATION_READ_FAILED" ||
error.code === "MODULE_CONFIGURATION_WRITE_FAILED"
) {
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(
c,
503,
fallback === "read" ? "MODULE_CONFIGURATION_READ_FAILED" : "MODULE_CONFIGURATION_WRITE_FAILED",
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const BODY_KEYS = new Set(["values"]);
/**
* The canonical text form of a submitted value, or `undefined` when it is not one this key accepts.
*
* Accepts JSON's own types — a number for `integer`, a boolean for `boolean`, a string for the rest
* — and converts to the single canonical text form the store persists and the resolver parses. It
* never accepts a string for a number (`"7"`), so exactly one spelling reaches storage and a stored
* row can never validate differently from the request that created it.
*/
function canonicalStoredValue(
declaration: ModuleConfigurationDeclaration,
submitted: unknown,
): string | undefined {
switch (declaration.type) {
case "integer": {
return typeof submitted === "number" && Number.isSafeInteger(submitted)
? String(submitted)
: undefined;
}
case "boolean": {
return typeof submitted === "boolean" ? String(submitted) : undefined;
}
case "string":
case "enum":
case "url": {
return typeof submitted === "string" &&
submitted.length <= MODULE_CONFIGURATION_VALUE_MAX_LENGTH
? submitted
: undefined;
}
case "secret": {
// Unreachable: a secret key is rejected before this is called. The branch exists so adding a
// type to the union is a compile error rather than a silent acceptance of secret material.
return undefined;
}
}
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
function declarationView(
declaration: ModuleConfigurationDeclaration,
storedValue: string | undefined,
secretConfigured: boolean,
): ModuleConfigurationKeyView {
const isSecret = declaration.type === "secret";
return {
// A secret is `configured` when the deployment supplies it; anything else when a value is
// stored. The two are different questions with one honest answer each.
configured: isSecret ? secretConfigured : storedValue !== undefined,
key: declaration.key,
required: declaration.required,
type: declaration.type,
...(declaration.description === undefined ? {} : { description: declaration.description }),
...(isSecret || declaration.default === undefined ? {} : { default: declaration.default }),
...(declaration.type === "enum" ? { values: declaration.values } : {}),
// The one place a stored value reaches a response — and structurally never for a secret.
...(isSecret || storedValue === undefined ? {} : { value: storedValue }),
};
}
/**
* The platform's module configuration resource: what each installed module declares it needs, what
* an operator has stored, and whether the running process is still using it.
*
* ## Secrets are absent, not filtered
*
* There is no branch here that redacts a secret, because there is nothing to redact: Ekklesia never
* persists secret material, so the store this route reads from contains none. A `secret` key's
* `value` field is omitted by construction and `configured` is computed by asking the deployment's
* secret source a yes/no question. A future code change that tried to serve a secret value would
* have to first invent somewhere to read one from.
*
* ## Changing configuration does not change the running process
*
* `PUT` replaces the stored document. It does not restart, reload, or reconfigure anything: modules
* receive a frozen snapshot resolved once at startup (ADR 0029), so the response reports
* `restartRequired` rather than pretending the change took effect.
*
* ## Closed validation against the module's own declaration
*
* A submitted key the module does not declare is rejected, as is a value of the wrong JSON type or a
* value failing the module's declared constraints. Nothing is coerced, truncated, or dropped:
* `{"slideSeconds": "7"}` is a `400`, not a silent `7`. A secret key is rejected outright — there is
* no supported way to submit secret material to this platform.
*/
export function createModuleConfigurationRoutes(
options: CreateModuleConfigurationRoutesOptions,
): Hono {
const routes = new Hono();
const requireManage = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_CONFIGURATION_MANAGE,
);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
const modulesById = new Map(
options.discoveredModules.map((module) => [module.manifest.id, module] as const),
);
const storedFor = (
settings: ModuleConfigurationSettings,
moduleId: string,
): ReadonlyMap<string, string> =>
new Map(
settings.records
.filter((record) => record.moduleId === moduleId)
.map((record) => [record.key, record.value] as const),
);
/** Two documents differ when the exact set of (key, value) pairs differs. Compared against the
* settings this process actually resolved at boot, so `restartRequired` is an observation rather
* than a guess. */
const differsFromBoot = (moduleId: string, current: ReadonlyMap<string, string>): boolean => {
const boot = storedFor(options.bootSettings, moduleId);
if (boot.size !== current.size) {
return true;
}
for (const [key, value] of current) {
if (boot.get(key) !== value) {
return true;
}
}
return false;
};
const buildView = (
module: DiscoveredModule,
settings: ModuleConfigurationSettings,
): ModuleConfigurationView => {
const stored = storedFor(settings, module.manifest.id);
const resolved = resolveModuleConfigurationFor(module.manifest, {
secrets: options.secrets,
settings,
});
return {
keys: module.manifest.configuration.map((declaration) =>
declarationView(
declaration as ModuleConfigurationDeclaration,
stored.get(declaration.key),
// Asked, never read into the response: the answer is a boolean, and the value it was
// derived from never leaves the resolver.
declaration.type === "secret"
? options.secrets.get(module.manifest.id, declaration.key) !== undefined
: false,
),
),
moduleId: module.manifest.id,
restartRequired: differsFromBoot(module.manifest.id, stored),
unknownKeys: resolved.unknownKeys,
version: module.manifest.version,
};
};
routes.get(MODULE_CONFIGURATION_MODULE_PATH, requireManage, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const moduleId = c.req.param("moduleId");
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
try {
return c.json(buildView(module, await options.store.readSettings()), 200);
} catch (error) {
return storeFailure(c, error, "read");
}
});
routes.put(
MODULE_CONFIGURATION_MODULE_PATH,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const moduleId = c.req.param("moduleId");
const raw = await readJsonBody(c);
if (!isRecord(raw) || Object.keys(raw).some((key) => !BODY_KEYS.has(key))) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const submitted = raw.values;
if (!isRecord(submitted)) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const module = moduleId === undefined ? undefined : modulesById.get(moduleId);
if (!module || !moduleIdSchema.safeParse(moduleId).success) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
const declarations = new Map(
module.manifest.configuration.map(
(declaration) =>
[declaration.key, declaration as ModuleConfigurationDeclaration] as const,
),
);
const values: { key: string; value: string }[] = [];
// `Object.keys` on a parsed JSON object never yields inherited properties, and every key is
// re-validated against the platform's one configuration-key grammar before it is used, so a
// body carrying `__proto__` or `constructor` is a plain `400` rather than anything stranger.
for (const key of Object.keys(submitted)) {
if (!moduleConfigurationKeySchema.safeParse(key).success) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
const declaration = declarations.get(key);
if (!declaration) {
return sanitizedFailure(c, 400, "CONFIGURATION_KEY_UNDECLARED");
}
if (declaration.type === "secret") {
// Deliberate and permanent for this pass: Ekklesia has no encryption at rest, so there is
// no honest way to accept secret material over HTTP and store it. Secrets are supplied by
// the deployment environment (ADR 0029).
return sanitizedFailure(c, 400, "CONFIGURATION_SECRET_NOT_WRITABLE");
}
const canonical = canonicalStoredValue(declaration, submitted[key]);
if (canonical === undefined) {
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
}
values.push({ key, value: canonical });
}
// Constraint validation runs through the **same resolver the platform boots with**, over the
// exact document about to be stored, rather than a second copy of the type rules here. That
// is what guarantees the API and startup can never disagree about whether a value is valid:
// there is one implementation, and this is it.
//
// An *incomplete* document is accepted deliberately — an operator may save partial work, and
// the module is then reported `blocked · configuration_missing` until they finish. An
// *invalid* one never is: storing a value that could not survive the next boot would hand
// back a success for a change that silently breaks the module.
const candidate = resolveModuleConfigurationFor(module.manifest, {
secrets: options.secrets,
settings: {
records: values.map((entry) => ({ ...entry, moduleId: module.manifest.id })),
},
});
if (candidate.issue?.reason === "configuration_invalid") {
return sanitizedFailure(c, 400, "CONFIGURATION_VALUE_INVALID");
}
try {
await options.store.replace({ moduleId: module.manifest.id, values });
// Re-read rather than predict, so this response and a later GET can never disagree.
return c.json(buildView(module, await options.store.readSettings()), 200);
} catch (error) {
return storeFailure(c, error, "write");
}
},
);
return routes;
}
+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)}`;
}
+301
View File
@@ -0,0 +1,301 @@
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
export {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
moduleDisplayDescriptorUrl,
moduleDisplayPageUrl,
} from "./module-display-path.js";
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import {
describeModuleContributions,
grantedModulePermissionIds,
type HostModuleContributionDescriptor,
} from "./module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
/**
* Where the display's offline service worker is served (ADR 0043).
*
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
*
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
*/
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
* management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the
* boundary. */
export interface HostModuleDisplayDescriptor {
readonly id: string;
readonly version: string;
readonly display: { readonly url: string };
/** The public page a screen opens to mount this display. This is distinct from `display.url`,
* which is the compiled artifact imported by the platform host. */
readonly page: { readonly url: string };
readonly api: { readonly baseUrl: string };
readonly realtime: { readonly baseUrl: string };
readonly permissions: readonly string[];
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
* mechanism a signage-style display uses to show another module's content without importing it. */
readonly contributions: readonly HostModuleContributionDescriptor[];
}
/**
* Who the host resolved as the viewer of this display.
*
* The descriptor reports it because the display host must not ask `/api/v1/principal` — that
* endpoint answers a question about *people*, and a paired device is deliberately anonymous to it
* (ADR 0036). Reading the viewer from the response that already performed the authorization means
* the display never has to infer "no human session, so presumably a device", and the two identity
* classes never have to be told apart by elimination.
*/
export type HostDisplayViewer =
| { readonly kind: "authenticated"; readonly userId: string }
| { readonly kind: "device"; readonly deviceId: string };
export interface HostModuleDisplayResource {
readonly module: HostModuleDisplayDescriptor;
readonly viewer: HostDisplayViewer;
}
export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same immutable, eligible/running publication registry used for artifact bytes. */
readonly moduleUi: ModuleUiPublicationRegistry;
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets;
}
/** A composition error, detected before any display route is registered. Carries no module id,
* permission id, path, or package location. */
export class ModuleDisplayConfigurationError extends Error {
constructor() {
super("The module display host configuration is inconsistent.");
this.name = "ModuleDisplayConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function displayFailure(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
function etagMatches(headerValue: string | undefined, etag: string): boolean {
if (headerValue === undefined) {
return false;
}
if (headerValue.trim() === "*") {
return true;
}
return headerValue.split(",").some((candidate) => {
const value = candidate.trim();
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
});
}
/** Copies and validates injected assets before registering any route. Production's loader already
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
* bytes with a stale validator. */
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
try {
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
if (
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
) {
throw new ModuleDisplayConfigurationError();
}
return snapshot;
} catch {
throw new ModuleDisplayConfigurationError();
}
}
function describeViewer(principal: EkklesiaPrincipal): HostDisplayViewer | undefined {
if (principal.kind === "authenticated") {
return { kind: "authenticated", userId: principal.userId };
}
if (principal.kind === "device") {
return { deviceId: principal.deviceId, kind: "device" };
}
// Unreachable behind the permission gate: the display permission is `restricted`, which no
// anonymous principal satisfies. Stated rather than assumed.
return undefined;
}
async function describeDisplay(
options: ModuleDisplayRoutesOptions,
principal: EkklesiaPrincipal,
display: PublishableModuleDisplay,
viewer: HostDisplayViewer,
): Promise<HostModuleDisplayResource> {
return {
viewer,
module: {
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
display: { url: moduleDisplayUiArtifactUrl(display) },
id: display.moduleId,
page: { url: moduleDisplayPageUrl(display.moduleId) },
permissions: await grantedModulePermissionIds(
options.evaluator,
principal,
display.permissions,
),
realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) },
version: display.version,
},
};
}
function mountDisplay(
app: Hono,
options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets },
display: PublishableModuleDisplay,
): void {
// The permission comes from an already-validated manifest/publication record. A request selects
// neither the permission nor the artifact; it can only hit this exact pre-composed route.
const requireDisplayPermission = requirePermission(options, display.displayPermission);
/**
* The page itself is **not** permission-gated, and the descriptor below still is.
*
* An unattended screen has no identity until someone pairs it, so a gate here would mean a new
* television could only ever be told 403 — there would be nowhere for it to display a pairing
* code, and the only way to adopt one would be to sign a human into it, which is exactly the
* model this pass exists to remove (ADR 0036).
*
* Serving it costs nothing, because it carries nothing: these are the same inert, module-agnostic
* bytes for every display, with no module id, no data, and one absolute script URL that was
* already public. Authorization lives entirely in the descriptor — the page can render, and then
* learns it may show only a pairing code. What this does reveal is that a module with a display
* exists at that path, which any pairing flow must reveal to the device it is pairing.
*/
app.get(moduleDisplayPageUrl(display.moduleId), () => {
return new Response(options.assets.html, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "text/html; charset=utf-8",
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
try {
const principal = getAuthorizedPrincipal(c);
const viewer = describeViewer(principal);
if (!viewer) {
return displayFailure(c);
}
return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch {
return displayFailure(c);
}
});
}
/**
* Mounts one stable page and one descriptor route per eligible, running display declaration.
*
* There is deliberately no display inventory in this pass. A management UI may need one later,
* but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only,
* disabled, and dependency-blocked modules have no registered route and collapse into the
* application's ordinary sanitized 404.
*
* The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute
* script URL cannot be rebased by a deep `/display/<id>` path. The browser reads the validated id
* from its outer URL, requests the permission-gated descriptor, and only then imports the exact
* versioned display artifact that the server supplied.
*/
export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void {
const displays = options.moduleUi.listDisplays();
if (displays.length === 0) {
return;
}
if (!options.assets) {
throw new ModuleDisplayConfigurationError();
}
const assets = snapshotAssets(options.assets);
for (const display of displays) {
if (!options.registry.has(display.displayPermission)) {
throw new ModuleDisplayConfigurationError();
}
}
// One fixed host bundle, never a directory and never a request-selected package file. Like module
// artifact bytes, fetching this platform code grants no authority; the page and descriptor are
// where identity and the module-owned display permission are enforced.
app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => {
if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) {
return new Response(null, {
headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag },
status: 304,
});
}
return new Response(assets.script, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/javascript; charset=utf-8",
ETag: assets.scriptEtag,
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
/**
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
* how a screen avoids being trapped on an obsolete worker.
*
* Like the host script and module artifact bytes, fetching this code grants no authority. It
* caches only public code and never a permission decision, so serving it anonymously withholds
* nothing the platform was protecting.
*/
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
return new Response(null, {
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
status: 304,
});
}
return new Response(assets.serviceWorker, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/javascript; charset=utf-8",
ETag: assets.serviceWorkerEtag,
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
});
for (const display of displays) {
mountDisplay(app, { ...options, assets }, display);
}
}
+66 -14
View File
@@ -1,9 +1,16 @@
import type { import {
ModuleId, EMPTY_MODULE_CONFIGURATION,
ModuleRouteHandlerContext, EMPTY_MODULE_STORAGE,
ModuleRuntimeServices, type ModuleId,
RegisteredModuleHttpRoute, type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import type {
ModuleConfigurationLookup,
ModuleRealtimeRegistry,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { import {
type AuthorizationMiddlewareOptions, type AuthorizationMiddlewareOptions,
@@ -31,6 +38,27 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked * denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
* after authorization succeeds. */ * after authorization succeeds. */
readonly services: ModuleRuntimeServices; readonly services: ModuleRuntimeServices;
/**
* Per-module frozen configuration snapshots — the exact map the module runtime started from
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
* rather than two independently resolved values. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
*
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
* ever reach its own module's configuration, because that is the only entry looked up for it.
*/
readonly configurations: ModuleConfigurationLookup;
/**
* Per-module scoped object storage — the exact map the module runtime started from (ADR 0030), so
* a handler and that module's lifecycle hooks share one facade rather than two independently
* resolved ones.
*
* Keyed by module id, like `configurations` and unlike `services`: a handler can only ever reach
* its own module's storage, because that is the only entry looked up for it.
*/
readonly storages: ModuleStorageLookup;
/** The same started registry whose scoped publishers lifecycle hooks receive. */
readonly realtime: ModuleRealtimeRegistry;
} }
/** Never carries the offending permission id, route path, module id, or any other configuration /** Never carries the offending permission id, route path, module id, or any other configuration
@@ -45,13 +73,30 @@ export class ModuleRouteConfigurationError extends Error {
} }
} }
/**
* The root a module's own HTTP API is mounted at — the one place that URL shape is constructed.
*
* Exported because a browser host has to resolve `context.api.request("records")`
* (`ModuleUiApiClient`, `@ekklesia/module-ui-sdk`) to a real Ekklesia URL, and a module UI must
* never build that prefix itself: it is Ekklesia's private deployment detail, and a module that
* hardcoded it could not be hosted anywhere else (ADR 0022). The server hands the resolved base to
* the host through the module UI inventory (`./module-ui-inventory.js`) rather than letting the
* browser re-spell this template, so this prefix can change without touching either.
*
* Percent-encoded for the same reason `moduleUiArtifactUrl` is: a value that somehow did not come
* from a validated manifest can only ever become one segment's worth of text, never a new segment.
* A real module id (lowercase kebab-case, `moduleIdSchema`) is unaffected by the encoding.
*/
export function moduleApiBaseUrl(moduleId: string): string {
return `${MODULE_ROUTE_PREFIX}/${encodeURIComponent(moduleId)}`;
}
/** "/" is the module's own root and maps to the bare module prefix, never a required trailing /** "/" is the module's own root and maps to the bare module prefix, never a required trailing
* slash; every other local path appends directly. Root and non-root therefore never collide, and * slash; every other local path appends directly. Root and non-root therefore never collide, and
* `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */ * `/api/v1/modules/<module-id>/` (the root path with a trailing slash) is never produced here. */
function moduleRoutePath(moduleId: ModuleId, routePath: string): string { function moduleRoutePath(moduleId: ModuleId, routePath: string): string {
return routePath === "/" const base = moduleApiBaseUrl(moduleId);
? `${MODULE_ROUTE_PREFIX}/${moduleId}` return routePath === "/" ? base : `${base}${routePath}`;
: `${MODULE_ROUTE_PREFIX}/${moduleId}${routePath}`;
} }
function sanitizedModuleHandlerFailure(c: Context): Response { function sanitizedModuleHandlerFailure(c: Context): Response {
@@ -156,9 +201,10 @@ function sanitizeModuleResponse(response: Response): Response {
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the * implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
* response body afterward — there is no separate `HEAD` registration or permission path. * response body afterward — there is no separate `HEAD` registration or permission path.
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a * `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
* frozen context carrying exactly `moduleId`, `principal`, `params`, and `services` — never a Hono * frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`,
* `Context`, authorization store, or configuration; `services.database` is the module's only * `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or
* reachable database surface, and is unreachable to anything short of a successfully authorized * deployment-wide configuration. `services.database` is the module's only reachable database
* surface, and the whole context is unreachable to anything short of a successfully authorized
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach * handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
* this handler at all). * this handler at all).
* *
@@ -189,15 +235,21 @@ export function mountModuleHttpRoutes(
const requireRoutePermission = requirePermission(middlewareOptions, route.permission); const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at // The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
// all) is enforced before permission evaluation ever runs, so an oversized request never // all) is enforced before permission evaluation ever runs, so a request *declared* oversized
// reaches authorization work, let alone the handler or the module database. // never reaches authorization work, let alone the handler or the module database. In
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes); // `streamed` mode no body byte is read until the handler reads it, and the handler runs only
// after authorization succeeds, so an unauthorized caller can never push an upload through
// (ADR 0042).
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path); const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => { const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({ const context: ModuleRouteHandlerContext = Object.freeze({
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
moduleId: route.moduleId, moduleId: route.moduleId,
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }), params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c), principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId),
services: options.services, services: options.services,
}); });
+286
View File
@@ -0,0 +1,286 @@
import type {
ModuleBlockedReason,
ModuleBlockingDependency,
ModuleRuntimeState,
ModuleSecretSource,
} from "@ekklesia/contracts";
import { CORE_MODULES_LIFECYCLE_MANAGE } from "@ekklesia/core/authorization";
import {
type ModuleConfigurationStore,
type ModuleLifecycleSnapshot,
type ModuleStorageAvailability,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
type ModuleLifecycleView,
resolveModuleLifecycleView,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
/**
* A **sibling** of `/api/v1/modules/`, never a child of it — the same rule ADR 0025 established for
* `/api/v1/module-ui`, and for the same concrete reason. Every module HTTP route is mounted under
* `/api/v1/modules/<module-id>/<local path>`, and `moduleRoutePathSchema` accepts `enabled` as an
* ordinary static segment, so a module named `announcements` registering `PUT /enabled` would land
* on exactly `/api/v1/modules/announcements/enabled`. Publishing the platform's own lifecycle
* mutation there would put Core and an installed module in one URL namespace with registration
* order deciding which one answers — for a route that changes whether modules run. A sibling prefix
* cannot collide.
*/
export const MODULE_LIFECYCLE_PATH = "/api/v1/module-lifecycle";
const MODULE_LIFECYCLE_ENABLED_PATH = `${MODULE_LIFECYCLE_PATH}/:moduleId/enabled`;
/**
* One module as an operator sees it. Two clearly separated axes, deliberately never merged into a
* single status word.
*
* `configured` is the decision that survives restarts. `runtime` is what *this process* did. They
* can legitimately disagree — Ekklesia does not hot-load or hot-unload modules — and
* `restartRequired` names that disagreement instead of hiding it behind a status that would be a
* lie in one direction or the other.
*
* Nothing here carries a filesystem path, a package name, a manifest path, a directory, an
* entrypoint, a caught error, or a stack. The only identifiers are module ids, capability ids, and
* a fixed reason code from a closed set — all of them values the platform itself produced and
* validated.
*/
export interface ModuleLifecycleModuleView {
readonly id: string;
readonly version: string;
readonly configured: {
readonly enabled: boolean;
/** `false` means nobody has decided and the platform default (enabled) applies. */
readonly explicit: boolean;
};
readonly runtime: {
readonly state: ModuleRuntimeState;
readonly reason?: ModuleBlockedReason;
/** Present for a dependency reason. */
readonly dependency?: ModuleBlockingDependency;
/** Present for a configuration reason (ADR 0029): the offending key, never its value. A key is
* declared in a public manifest; a value may be a credential or arbitrary operator text. */
readonly configurationKey?: string;
/** Present for a storage reason (ADR 0030): the logical bucket the module declared, never an
* endpoint, a credential, or the physical bucket the host maps it to. */
readonly storageBucketId?: string;
};
readonly restartRequired: boolean;
}
export interface ModuleLifecycleResponse {
readonly modules: readonly ModuleLifecycleModuleView[];
}
export interface CreateModuleLifecycleRoutesOptions extends AuthorizationMiddlewareOptions {
/** The boot snapshot: the discovered inventory this process started from, the eligibility it
* resolved, and the modules the runtime reports it actually started. Captured once at startup and
* never recomputed — this endpoint describes the platform that is running, not one that would run
* if it were restarted right now. */
readonly snapshot: ModuleLifecycleSnapshot;
readonly store: ModuleLifecycleStore;
/**
* How to re-read the *other* axis that decides eligibility (ADR 0029). Since a module's own
* configuration can block it, `restartRequired` must be computed against live configuration as
* well as live enablement — otherwise correcting a missing required value would report no pending
* restart for a module the next boot really will start.
*/
readonly configuration: {
readonly store: ModuleConfigurationStore;
readonly secrets: ModuleSecretSource;
};
/** The other axis that decides eligibility (ADR 0030). A boolean, never a probe: whether storage
* is *configured* decides eligibility, while whether it is *reachable right now* never does. */
readonly storage: ModuleStorageAvailability;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary, exactly as
* `dashboard-routes.ts` does. */
function storeFailure(c: Context, error: unknown, fallback: "read" | "write"): Response {
if (error instanceof ModuleLifecycleStoreError) {
if (
error.code === "MODULE_LIFECYCLE_READ_FAILED" ||
error.code === "MODULE_LIFECYCLE_WRITE_FAILED"
) {
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(
c,
503,
fallback === "read" ? "MODULE_LIFECYCLE_READ_FAILED" : "MODULE_LIFECYCLE_WRITE_FAILED",
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const ENABLED_BODY_KEYS = new Set(["enabled"]);
/**
* Structured, closed validation — never a configuration blob. Any key beyond `enabled` makes the
* whole body invalid rather than being silently dropped, so a caller cannot smuggle a field a
* future version might interpret, and a typo fails loudly instead of quietly doing nothing. Mirrors
* `parseGroupBody`/`parsePlacementBody` (`./dashboard-routes.js`) exactly.
*/
function parseEnabledBody(raw: unknown): { enabled: boolean } | undefined {
if (!isRecord(raw) || Object.keys(raw).some((key) => !ENABLED_BODY_KEYS.has(key))) {
return undefined;
}
return typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : undefined;
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
function present(view: ModuleLifecycleView): ModuleLifecycleModuleView {
return {
configured: { enabled: view.configured.enabled, explicit: view.configured.explicit },
id: view.moduleId,
restartRequired: view.restartRequired,
runtime: {
state: view.runtime.state,
...(view.runtime.reason === undefined ? {} : { reason: view.runtime.reason }),
...(view.runtime.dependency === undefined ? {} : { dependency: view.runtime.dependency }),
...(view.runtime.configurationKey === undefined
? {}
: { configurationKey: view.runtime.configurationKey }),
...(view.runtime.storageBucketId === undefined
? {}
: { storageBucketId: view.runtime.storageBucketId }),
},
version: view.version,
};
}
/**
* The platform's operational lifecycle resource: which installed modules are enabled, what this
* process actually did with each of them, and the narrow, structured API for changing that.
*
* ## Enablement is host configuration, and structurally cannot be anything else
*
* A module neither declares nor reads its own enabled state. Nothing in a manifest, a capability, a
* permission declaration, or a lifecycle hook can influence a value stored here, and a module is
* never told it is enabled — its host simply loads and starts it, or does not. That is what keeps
* the same module implementation reusable under a future standalone host, whose loading decisions
* are its own (ADR 0028).
*
* ## Changing configuration does not change the running process
*
* `PUT` writes a decision. It does not start, stop, load, unload, or reconfigure anything: there is
* no hot module installation or removal in this platform (ADR 0015, unchanged). The response
* therefore reports the new configuration *and* the unchanged runtime observation *and*
* `restartRequired`, rather than pretending the module stopped. Hot-unloading a module to make a
* button feel immediate would trade a real guarantee — that a module's ESM evaluation happens
* exactly once, and can never be rolled back — for a cosmetic one.
*
* ## Only a module this process can see may be configured
*
* `PUT` against an id that is not in the boot inventory is a `404`. Unlike an inert Dashboard
* placement, an enablement record is operational configuration, and accepting one for an arbitrary
* identifier would make this an unbounded write surface for a delegable permission. *Existing*
* records for modules that are no longer deployed are still retained and simply ignored, so
* redeploying a module restores the decision (see `resolveModuleEligibility`).
*
* ## Reading is administrative too
*
* Both verbs require `core.modules.lifecycle.manage` (restricted). This resource is the only place
* a disabled module, a blocked module, and the required-dependency edges between them are visible;
* `core.modules.ui.read` deliberately covers only what is mountable. Mutations additionally require
* an exact trusted `Origin` and a bounded body before authorization work begins, exactly like every
* other Core mutation.
*/
export function createModuleLifecycleRoutes(options: CreateModuleLifecycleRoutesOptions): Hono {
const routes = new Hono();
const requireManage = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_LIFECYCLE_MANAGE,
);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
const installedModuleIds = new Set(
options.snapshot.discoveredModules.map((module) => module.manifest.id),
);
routes.get(MODULE_LIFECYCLE_PATH, requireManage, async (c) => {
c.header("Cache-Control", "no-store");
// Not per-principal in content, but it is cookie-authorized, so it varies for the same reason
// every other authorized Core read does.
c.header("Vary", "Origin, Cookie");
try {
const [configuration, settings] = await Promise.all([
options.store.readConfiguration(),
options.configuration.store.readSettings(),
]);
const modules = resolveModuleLifecycleView(
options.snapshot,
configuration,
{ secrets: options.configuration.secrets, settings },
options.storage,
).map(present);
return c.json({ modules } satisfies ModuleLifecycleResponse, 200);
} catch (error) {
return storeFailure(c, error, "read");
}
});
routes.put(
MODULE_LIFECYCLE_ENABLED_PATH,
requireOrigin,
mutationBodyLimit,
requireManage,
async (c) => {
c.header("Cache-Control", "no-store");
const moduleId = c.req.param("moduleId");
const body = parseEnabledBody(await readJsonBody(c));
if (!body) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
if (!installedModuleIds.has(moduleId)) {
return sanitizedFailure(c, 404, "MODULE_NOT_INSTALLED");
}
try {
await options.store.setEnabled({ enabled: body.enabled, moduleId });
// Re-read rather than predict: the response describes the configuration that is actually
// stored now, resolved against the same boot snapshot the collection read uses, so the two
// endpoints can never disagree about `restartRequired`.
const [configuration, settings] = await Promise.all([
options.store.readConfiguration(),
options.configuration.store.readSettings(),
]);
const view = resolveModuleLifecycleView(
options.snapshot,
configuration,
{ secrets: options.configuration.secrets, settings },
options.storage,
).find((entry) => entry.moduleId === moduleId);
if (!view) {
return sanitizedFailure(c, 503, "MODULE_LIFECYCLE_READ_FAILED");
}
return c.json(present(view), 200);
} catch (error) {
return storeFailure(c, error, "write");
}
},
);
return routes;
}
+252
View File
@@ -0,0 +1,252 @@
import { ModuleRealtimeError, type ModuleId, type PermissionId } from "@ekklesia/contracts";
import type {
ModuleRealtimePublication,
ModuleRealtimeRegistry,
RegisteredModuleRealtimeChannel,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
} from "./authorization-middleware.js";
const MODULE_REALTIME_PREFIX = "/api/v1/module-realtime";
/** Host-owned keepalive interval. Comments keep idle HTTP streams alive without entering module code,
* consuming a sequence, or becoming a browser-visible state update. */
export const MODULE_REALTIME_KEEPALIVE_MS = 15_000;
/** Forces a fresh cookie/permission evaluation periodically rather than letting an authorization
* decision live for an unbounded stream lifetime. EventSource reconnects after the clean EOF. */
export const MODULE_REALTIME_MAX_CONNECTION_AGE_MS = 5 * 60_000;
export interface ModuleRealtimeTransportTiming {
/** Test-only narrowing of the production keepalive. Production composition never sets this. */
readonly keepaliveMs?: number;
/** Test-only narrowing of the production reauthorization interval. */
readonly maxConnectionAgeMs?: number;
}
export interface ModuleRealtimeRoutesOptions
extends AuthorizationMiddlewareOptions,
ModuleRealtimeTransportTiming {
readonly realtime: ModuleRealtimeRegistry;
}
/** The server is the sole author of this deployment-private base. Module UI receives it through the
* host descriptor and never reconstructs the platform prefix or supplies its own module id. */
export function moduleRealtimeBaseUrl(moduleId: string): string {
return `${MODULE_REALTIME_PREFIX}/${encodeURIComponent(moduleId)}`;
}
function moduleRealtimeChannelUrl(moduleId: ModuleId, channel: string): string {
return `${moduleRealtimeBaseUrl(moduleId)}/${encodeURIComponent(channel)}`;
}
/** A forged runtime snapshot that could not be permission-gated is a composition failure. Carries no
* module/channel/permission value and is thrown before any route from that snapshot is usable. */
export class ModuleRealtimeConfigurationError extends Error {
constructor() {
super("A published module realtime channel references a permission that is not registered.");
this.name = "ModuleRealtimeConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function realtimeEnvelope(publication: ModuleRealtimePublication): string {
// `serializedPayload` was validated and serialized exactly once by Core. Concatenating it as the
// JSON value preserves every opaque string — including signed object-delivery URLs — byte for byte.
return `{"sequence":${publication.sequence},"payload":${publication.serializedPayload}}`;
}
function realtimeEvent(
kind: "snapshot" | "update",
publication: ModuleRealtimePublication,
): string {
// Core's compact JSON serialization contains no literal CR/LF bytes inside strings, so one `data`
// line is sufficient. Deliberately no SSE `id`: this transport does not implement Last-Event-ID
// replay, and a process-local sequence must not be mistaken for a durable cursor.
return `event: ${kind}\ndata: ${realtimeEnvelope(publication)}\n\n`;
}
function sanitizedRealtimeFailure(c: Context, error: unknown): Response {
c.header("Cache-Control", "no-store");
const code =
error instanceof ModuleRealtimeError && error.code === "REALTIME_SUBSCRIBER_LIMIT"
? "REALTIME_SUBSCRIBER_LIMIT"
: "REALTIME_UNAVAILABLE";
return c.json({ code }, 503);
}
interface CancelableDelay {
readonly promise: Promise<"keepalive">;
cancel(): void;
}
function keepaliveDelay(milliseconds: number): CancelableDelay {
let timer: ReturnType<typeof setTimeout> | undefined;
const promise = new Promise<"keepalive">((resolve) => {
timer = setTimeout(() => resolve("keepalive"), milliseconds);
});
return {
cancel: () => clearTimeout(timer),
promise,
};
}
function createRealtimeStream(
definition: RegisteredModuleRealtimeChannel,
registry: ModuleRealtimeRegistry,
keepaliveMs: number,
maxConnectionAgeMs: number,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const subscription = registry.subscribe(definition.moduleId, definition.channel);
let snapshotPending = true;
let closed = false;
let pendingRead: Promise<ModuleRealtimePublication | undefined> | undefined;
let activeDelay: CancelableDelay | undefined;
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
const close = (): void => {
if (closed) {
return;
}
closed = true;
activeDelay?.cancel();
activeDelay = undefined;
clearTimeout(expiryTimer);
expiryTimer = undefined;
subscription.close();
};
return new ReadableStream<Uint8Array>({
start(controller) {
expiryTimer = setTimeout(() => {
close();
try {
controller.close();
} catch {
// A simultaneous browser cancellation or completed pull already closed the controller.
}
}, maxConnectionAgeMs);
},
cancel: () => close(),
async pull(controller) {
if (closed) {
return;
}
try {
if (snapshotPending) {
snapshotPending = false;
controller.enqueue(encoder.encode(realtimeEvent("snapshot", subscription.snapshot)));
return;
}
// Only one Core read is ever outstanding. If a keepalive wins the race, the same read is
// retained for the next pull instead of allocating a second listener. Core itself holds only
// one newest pending publication, and this stream asks for work only when the browser-facing
// ReadableStream has demand, so a slow client cannot create an array at either layer.
pendingRead ??= subscription.read();
activeDelay = keepaliveDelay(keepaliveMs);
const result = await Promise.race([pendingRead, activeDelay.promise]);
activeDelay.cancel();
activeDelay = undefined;
if (result === "keepalive") {
controller.enqueue(encoder.encode(": keepalive\n\n"));
return;
}
pendingRead = undefined;
if (result === undefined) {
close();
controller.close();
return;
}
controller.enqueue(encoder.encode(realtimeEvent("update", result)));
} catch {
close();
try {
controller.close();
} catch {
// The browser may have canceled between the awaited read/timer and this close. Cancellation
// already performed the same idempotent subscription cleanup.
}
}
},
});
}
function mountChannel(
app: Hono,
definition: RegisteredModuleRealtimeChannel,
options: ModuleRealtimeRoutesOptions,
keepaliveMs: number,
maxConnectionAgeMs: number,
): void {
const requireChannelPermission = requirePermission(
options,
definition.permission as PermissionId,
);
app.get(
moduleRealtimeChannelUrl(definition.moduleId, definition.channel),
requireChannelPermission,
(c) => {
// Hono deliberately lets a registered GET handler answer HEAD. That is useful for finite
// resources, but unsafe for an infinite stream: the adapter discards the response body without
// necessarily canceling it, which could retain a Core subscriber forever. Refuse HEAD before
// allocating anything. Authentication/authorization has still run through the same middleware.
if (c.req.method !== "GET") {
return new Response(null, {
headers: { Allow: "GET", "Cache-Control": "no-store" },
status: 405,
});
}
let stream: ReadableStream<Uint8Array>;
try {
stream = createRealtimeStream(
definition,
options.realtime,
keepaliveMs,
maxConnectionAgeMs,
);
} catch (error) {
return sanitizedRealtimeFailure(c, error);
}
return new Response(stream, {
headers: {
"Cache-Control": "no-store",
Connection: "keep-alive",
"Content-Type": "text/event-stream; charset=utf-8",
Vary: "Origin, Cookie",
// Honored by nginx and harmless elsewhere. Other proxies must likewise disable buffering for
// this path; no proxy-specific behavior enters the module-facing contract.
"X-Accel-Buffering": "no",
},
status: 200,
});
},
);
}
/** Mounts one exact, immutable, permission-gated GET route per successfully started module channel.
* There is no dynamic module/channel lookup route and no request-controlled permission selection. */
export function mountModuleRealtimeRoutes(app: Hono, options: ModuleRealtimeRoutesOptions): void {
const keepaliveMs = options.keepaliveMs ?? MODULE_REALTIME_KEEPALIVE_MS;
const maxConnectionAgeMs = options.maxConnectionAgeMs ?? MODULE_REALTIME_MAX_CONNECTION_AGE_MS;
if (!Number.isSafeInteger(keepaliveMs) || keepaliveMs <= 0) {
throw new TypeError("The module realtime keepalive interval is invalid.");
}
if (!Number.isSafeInteger(maxConnectionAgeMs) || maxConnectionAgeMs <= 0) {
throw new TypeError("The module realtime maximum connection age is invalid.");
}
for (const definition of options.realtime.channels) {
if (!options.registry.has(definition.permission)) {
throw new ModuleRealtimeConfigurationError();
}
}
for (const definition of options.realtime.channels) {
mountChannel(app, definition, options, keepaliveMs, maxConnectionAgeMs);
}
}
+296
View File
@@ -0,0 +1,296 @@
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
import type {
ModuleUiPublicationRegistry,
PublishableModuleContribution,
PublishableModuleUi,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleDisplayPageUrl } from "./module-display-path.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import {
MODULE_UI_ASSET_PREFIX,
moduleContributionArtifactUrl,
moduleUiArtifactUrl,
} from "./module-ui-routes.js";
/**
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
* mount, and `/api/v1/module-ui/<module-id>/<version>` (ADR 0025) serves one artifact's bytes. One
* prefix, one resource, two representations.
*/
export const MODULE_UI_INVENTORY_PATH = MODULE_UI_ASSET_PREFIX;
/**
* Exactly what a browser host needs to mount one module's UI, and deliberately nothing else.
*
* No filesystem path, package root, package name, server/schema/migrations entrypoint, manifest
* version, capability, dependency, or storage bucket appears here. Those are server-side facts a
* browser has no use for, and several of them are precisely what ADR 0025's publication route
* exists to keep off the wire.
*
* There is also no `name`, `icon`, `description`, `category`, `folder`, or `order`. Those are
* *navigation* concerns, and ADR 0021 established that Dashboard grouping is presentation
* configuration an administrator owns rather than something a module declares. A field added here
* now would quietly become module-owned navigation metadata before that design exists; the
* temporary selector renders the module id instead.
*/
export interface HostModuleUiDescriptor {
readonly id: string;
readonly version: string;
/** Where the browser fetches this module's compiled UI artifact. Produced by the server's own
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
* routing can change without touching a single line of host or module code. */
readonly ui: { readonly url: string };
/** Canonical page for this module's separately declared display artifact, or `null` when the
* module publishes no display. Server-produced so neither shell nor module spells platform
* routing or infers deployment topology. */
readonly display: { readonly url: string } | null;
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
* module nor the browser host should be the one spelling it. */
readonly api: { readonly baseUrl: string };
/** Server-produced root for this module's host-neutral browser realtime facade. The browser host,
* not module UI, resolves and appends a validated local channel. */
readonly realtime: { readonly baseUrl: string };
/**
* The subset of **this module's own declared permissions** that the current principal holds,
* evaluated server-side by Core's one authorization evaluator.
*
* Scoped to the module deliberately: a host has no business handing one module the platform's
* whole authorization state, and the module's manifest already declares exactly which permissions
* its UI can meaningfully ask about. Granted ids only — a permission the principal lacks is simply
* absent, so this never enumerates a module's full permission surface to someone who holds none of
* it.
*
* **These are presentation hints, not authority** (ADR 0022). They exist so a UI can hide a
* control instead of offering one that would fail. Every module route remains enforced by
* `requirePermission` against the composed platform registry, and that enforcement neither reads
* nor trusts anything in this array.
*/
readonly permissions: readonly string[];
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
* the overwhelming majority of modules, which provide no extension point. */
readonly contributions: readonly HostModuleContributionDescriptor[];
}
/**
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
*
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
* consuming module names a contribution and the host resolves it, so no module ever builds
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
*
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
* contributed renderer draws and what shape its input takes are the consuming module's contract,
* carried in the browser between two modules without passing through the platform.
*/
export interface HostModuleContributionDescriptor {
readonly id: string;
readonly version: string;
/** The capability it was contributed to — one this module declares. */
readonly capability: string;
readonly url: string;
}
export interface HostModuleUiInventory {
readonly modules: readonly HostModuleUiDescriptor[];
}
/**
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
* inventory and the display descriptor so the two can never disagree about what a consuming module
* may mount.
*/
export function describeModuleContributions(
registry: ModuleUiPublicationRegistry,
moduleId: string,
): readonly HostModuleContributionDescriptor[] {
return registry
.contributionsForModule(moduleId)
.map((contribution: PublishableModuleContribution) => ({
capability: contribution.capability,
id: contribution.contributionId,
url: moduleContributionArtifactUrl(
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
contribution.contributionId,
),
version: contribution.version,
}));
}
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
/** The same publication registry the artifact route serves from — the single accepted/publishable
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
* no discovery, reads no manifest, and touches no filesystem; a module absent from this registry
* is absent from the inventory, by construction rather than by filtering. Named `moduleUi` rather
* than `registry` because `AuthorizationMiddlewareOptions.registry` is already the *permission*
* registry, and one route needs both. */
readonly moduleUi: ModuleUiPublicationRegistry;
}
/**
* Evaluates one module's declared permissions against the caller, returning only the granted ids.
*
* Uses Core's one evaluator, so baselines, the platform-owner bypass, grant expiry, and
* deny-by-default all behave exactly as they do on a real request — there is no second,
* browser-only interpretation of what a permission means. An infrastructure failure propagates as
* `AuthorizationEvaluationError` rather than being folded into "not granted", because a hint that
* silently read "denied" during a database outage would blank the whole UI and look like a
* permissions problem.
*/
/** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same
* presentation-only subset of a module's own declarations and use Core's one evaluator. */
export async function grantedModulePermissionIds(
evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal,
declarations: readonly PermissionDeclaration[],
): Promise<readonly string[]> {
const decisions = await Promise.all(
declarations.map(async (declaration) => ({
allowed: (
await evaluator.authorize({
baseline: declaration.baseline,
permission: declaration.id,
principal,
})
).allowed,
id: String(declaration.id),
})),
);
return decisions.filter((decision) => decision.allowed).map((decision) => decision.id);
}
async function describeModule(
evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal,
published: PublishableModuleUi,
registry: ModuleUiPublicationRegistry,
): Promise<HostModuleUiDescriptor> {
const display = registry.resolveDisplay(published.moduleId, published.version);
return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
contributions: describeModuleContributions(registry, published.moduleId),
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
id: published.moduleId,
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
ui: { url: moduleUiArtifactUrl(published) },
version: published.version,
};
}
/** One mountable module, described once: the host-neutral mount descriptor plus the module's own
* display name, kept beside it rather than inside it. */
export interface MountableModuleDescription {
readonly descriptor: HostModuleUiDescriptor;
/** `manifest.name`. Presentation *identity* the module owns — never its placement, which the
* Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the
* host-neutral contract a standalone host consumes stays free of presentation concerns. */
readonly name: string;
}
/**
* The one composition of the mountable-module set, shared by the generic inventory route and the
* Dashboard navigation route (`./dashboard-routes.js`).
*
* Both derive from the same publication registry and the same evaluator, so the two endpoints can
* never disagree about what exists, what its URLs are, or what the caller may do inside it. Sharing
* this function rather than duplicating the mapping is what makes that a structural property instead
* of a convention.
*/
export async function describeMountableModules(
evaluator: AuthorizationMiddlewareOptions["evaluator"],
principal: EkklesiaPrincipal,
registry: ModuleUiPublicationRegistry,
): Promise<readonly MountableModuleDescription[]> {
return Promise.all(
registry.list().map(async (published) => ({
descriptor: await describeModule(evaluator, principal, published, registry),
name: published.name,
})),
);
}
/** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that
* could not be determined is never reported as "not granted". Carries no module id, permission id,
* or caught error. */
function sanitizedInventoryFailure(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
/**
* The browser host's one bootstrap query: which module UIs may I mount, where do I fetch each one,
* where does each one's API live, and what may this viewer do inside it.
*
* ## It is not a module management API
*
* It lists mountable UIs and nothing else. There is no create, update, enable, disable, install, or
* configure; no module that declares no UI; and no field beyond what constructing a
* `ModuleUiHostContext` requires. Ekklesia has no enable/disable state to report —
* `ModuleLifecycleStage` is vocabulary with no runtime state machine behind it — and inventing one
* here would be inventing a lifecycle rather than reporting one.
*
* ## The module set comes from the publication registry, never a rescan
*
* `moduleUi.list()` is the exact accepted set ADR 0025 publishes artifacts from: modules that
* survived discovery and declare `entrypoints.ui`, captured after the module runtime started. This
* route never scans a directory, reads a manifest, or reconstructs a module from `package.json`, so
* the inventory and the artifact route can never disagree about what exists. Order is the
* registry's own discovery order, which is deterministic (topologically sorted, then by id).
*
* ## Authenticated, deliberately, unlike the artifact route
*
* ADR 0025 made the artifact bytes anonymous — fetching compiled UI code confers no authority — but
* was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong
* version collapse into one indistinguishable `404`, so a caller must already know both halves of an
* identity to learn anything. **This endpoint is that enumeration.** Serving it anonymously would
* hand over exactly what that route withholds, so it requires `core.modules.ui.read` (baseline
* `authenticated`): any signed-in user of the installation, and nobody else.
*
* Enforcement goes through the same `requirePermission` middleware every other protected route uses
* — no second browser-auth mechanism — which also resolves the principal exactly once and keeps a
* genuine denial (`403`) distinct from an infrastructure failure (`503`).
*
* The response is per-principal (its `permissions` depend on who is asking), so it is `no-store` and
* varies on `Origin` and `Cookie`, exactly like `/api/v1/principal`.
*/
export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRoutesOptions): Hono {
const routes = new Hono();
const requireInventoryPermission = requirePermission(
{ evaluator: options.evaluator, registry: options.registry, runtime: options.runtime },
CORE_MODULES_UI_READ,
);
routes.get(MODULE_UI_INVENTORY_PATH, requireInventoryPermission, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
// Resolved and already authorized by the middleware above; never resolved a second time here.
// The `authenticated` baseline guarantees this is an authenticated principal.
const principal = getAuthorizedPrincipal(c);
try {
const described = await describeMountableModules(
options.evaluator,
principal,
options.moduleUi,
);
// Only the host-neutral descriptor reaches this response: the display name stays out of the
// portable contract and is carried by the Dashboard navigation view instead.
const modules = described.map((entry) => entry.descriptor);
return c.json({ modules } satisfies HostModuleUiInventory, 200);
} catch {
return sanitizedInventoryFailure(c);
}
});
return routes;
}
+289
View File
@@ -0,0 +1,289 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
/**
* The platform-owned URL space for published module UI artifacts.
*
* Deliberately **not** nested under `/api/v1/modules/<module-id>`, and that is a security decision
* rather than a stylistic one. A module may declare any route path matching Core's own grammar
* (`moduleRoutePathSchema`, `@ekklesia/contracts`), and `ui` is a perfectly legal lowercase static
* segment — so a module registering `GET /ui/:version` would be mounted at exactly
* `/api/v1/modules/<module-id>/ui/<version>`. Publishing there would put the platform and installed
* modules in the same URL namespace, with Hono's registration order silently deciding which one
* answers. A sibling prefix can never collide: every module route begins `/api/v1/modules/`, and
* nothing the platform serves here does.
*
* It stays under `/api/v1/*` so it inherits the application's one CORS policy (exact reflected
* trusted origins, never a wildcard) — which a cross-origin browser `import()` of this URL requires
* — instead of introducing a second, separately-configured public URL space.
*/
export const MODULE_UI_ASSET_PREFIX = "/api/v1/module-ui";
/**
* Revalidate-always, with a strong `ETag` doing the actual work.
*
* The URL carries module id and exact installed version, which is *identity*, not *immutability*.
* Ekklesia has no installer, no content addressing, and no integrity verification, so nothing stops
* an operator from rebuilding version `1.2.3` with different bytes and restarting — the ordinary
* inner-loop of developing a module. `immutable` (or any non-zero `max-age`) would therefore be a
* promise the platform cannot keep, and a browser that cached a superseded artifact under a reused
* version would have no way to recover.
*
* `no-cache` is not `no-store`: the response is still cached, it just has to be revalidated. With
* the `ETag` below, an unchanged artifact costs one conditional request and a `304`, and a changed
* one is picked up immediately. A version bump changes the URL and never collides at all. When a
* real installer can guarantee that an installed version's bytes never change, this is the single
* constant that would move to immutable caching.
*/
export const MODULE_UI_CACHE_CONTROL = "no-cache";
/** Modern ESM is served as `text/javascript`, the one JavaScript type the WHATWG MIME sniffing
* standard designates as canonical; `application/javascript` is a legacy alias. A browser refuses
* to evaluate an ES module served as anything else, so this is a correctness requirement, not a
* courtesy. */
export const MODULE_UI_CONTENT_TYPE = "text/javascript; charset=utf-8";
export interface ModuleUiIdentity {
readonly moduleId: string;
readonly version: string;
}
/**
* The one canonical way to turn an installed module's identity into the browser URL its UI artifact
* is published at. Every producer of that URL — this package's own route registration, a future
* module-metadata endpoint, and a future Dashboard host — goes through here, so the URL shape is
* defined once instead of being re-spelled as a template string in each of them.
*
* The result is a **root-relative browser URL, never a filesystem path** — the distinction ADR 0024
* built the loader around. A host on the API's own origin passes it to `loadModuleUi` unchanged; a
* host on a different origin resolves it against the same API base URL it already needs for
* `ModuleUiApiClient` (`new URL(moduleUiArtifactUrl(identity), apiBaseUrl)`). It is deliberately not
* absolute: the API's public origin is deployment configuration, and baking one in here would break
* both split-origin deployments and reverse proxies.
*
* This function accepts **identity only**. There is no parameter for a filename, a relative path,
* or any other artifact selector, so no caller — however careless — can use it to reach a second
* file in a module package. Both components are percent-encoded, so even a value that never came
* from a validated manifest can only ever produce one extra path segment's worth of text, never a
* new segment, a traversal, a query, or a fragment. A value that needed encoding will simply fail
* to resolve against the publication registry.
*/
export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(identity.moduleId)}/${encodeURIComponent(
identity.version,
)}`;
}
/**
* Canonical browser URL for a module's independently declared display artifact.
*
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
* separate helper and an exact route means neither request input nor a generic role parameter can
* select a neighbouring artifact. A future standalone host may expose a different outer display
* page while consuming this same published module artifact internally.
*/
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${moduleUiArtifactUrl(identity)}/display`;
}
/**
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
*
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
* module id and version are: a value that somehow did not come from a validated manifest can only
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
* fragment — and a value that needed encoding simply fails to resolve against the registry.
*
* Like every other publication helper, this takes **identity only**. There is no parameter for a
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
* module package.
*/
export function moduleContributionArtifactUrl(
identity: ModuleUiIdentity,
contributionId: string,
): string {
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
}
/**
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
* installed module declares no artifact for the requested UI role, or the requested version is not
* the installed one.
*
* They are deliberately indistinguishable. The route requires no authentication (see
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
* which modules an installation runs and at which versions. Collapsing them means a caller must
* already know both the exact module id and the exact version to learn anything at all.
*/
function notPublished(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_UI_NOT_PUBLISHED" }, 404);
}
/**
* The artifact was validated at discovery and is not retrievable now — deleted, replaced by a
* directory, or unreadable. That is a server-side condition rather than a bad request, and often a
* transient one (an operator mid-upgrade), so it reports like every other "could not be determined"
* result in this application: a sanitized 503 with a fixed code. Never carries the artifact path,
* the module directory, the `errno`, or the caught error.
*/
function artifactUnavailable(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }, 503);
}
/**
* RFC 9110 §13.1.2 evaluation of `If-None-Match`: `*` matches any existing representation, and
* every listed entity-tag is compared with the **weak** comparison function, so a client echoing
* `W/"…"` still matches the strong tag this route issued. Anything unparsable simply fails to
* match, which costs a full 200 and can never produce a wrongly-fresh 304.
*/
function isNotModified(headerValue: string | undefined, etag: string): boolean {
if (headerValue === undefined) {
return false;
}
if (headerValue.trim() === "*") {
return true;
}
return headerValue.split(",").some((candidate) => {
const value = candidate.trim();
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
});
}
interface PublishedArtifact {
readonly artifactPath: string;
}
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
// Discovery validated this exact path earlier; the file may still have changed or vanished
// since. The response is produced from whatever a single read of that one path returns right
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
// something else.
let artifact: Buffer;
try {
artifact = await readFile(published.artifactPath);
} catch {
return artifactUnavailable(c);
}
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
// validator can never describe a different revision than the body does.
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
if (isNotModified(c.req.header("if-none-match"), etag)) {
return new Response(null, {
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
status: 304,
});
}
return new Response(artifact, {
headers: {
"Cache-Control": MODULE_UI_CACHE_CONTROL,
"Content-Type": MODULE_UI_CONTENT_TYPE,
ETag: etag,
// The declared type is the only type: a browser must not be allowed to sniff a module
// artifact into some other interpretation, and `import()` requires the JavaScript type
// anyway.
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
}
export interface CreateModuleUiRoutesOptions {
/** The platform's publication policy, built by Core from the discovered, started module
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
* every byte it serves from this registry and nothing else. */
readonly registry: ModuleUiPublicationRegistry;
}
/**
* Publishes installed modules' validated `entrypoints.ui` artifacts at a platform-owned browser
* URL, closing the one gap between Core's server-side artifact path and the generic loader's
* browser-side URL (ADR 0024, ADR 0025).
*
* ## The request never touches the filesystem
*
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
* components**. They are handed to the registry and, on an exact match, the already-canonical
* `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
* are all unreachable by construction rather than by filtering. An installed module directory never
* becomes a static file root, and only the single artifact its manifest declares is reachable.
*
* ## No authentication, deliberately
*
* Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an
* operator reviewed and installed; it carries no credential, and the host — not the artifact —
* supplies the principal and permissions at mount time. Every module API stays behind
* `requirePermission` against the composed platform registry, and nothing about fetching this file
* changes a single authorization decision.
*
* Requiring a session would also break the thing it is meant to protect: a browser's dynamic
* `import()` uses `credentials: "same-origin"`, so a cross-origin import (the split-origin
* deployments `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support) sends no cookie and would fail
* — a failure that tests green on a server and only shows up in a real browser, exactly the class of
* error ADR 0024 exists to prevent. Working around it would mean fetching the bytes and importing a
* `blob:` URL, which costs a host its `script-src` CSP.
*
* The honest cost is stated in ADR 0025: an anonymous caller who already knows a module id **and**
* its exact installed version can download that module's UI bundle. That is information disclosure,
* not authority, and it is the reason the failure responses above are indistinguishable.
*
* ## The artifact is never executed
*
* This route reads bytes and writes bytes. It does not `import()`, evaluate, transform, minify,
* re-bundle, or rewrite the artifact, and a module UI whose top level throws is served exactly like
* any other. Core's rule that discovery never executes `entrypoints.ui` (ADR 0021) is unchanged, and
* publication does not become a second, quieter way to run browser code on the server.
*/
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
const routes = new Hono();
routes.get(
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
async (c) => {
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
// UI artifact and on no others.
const published = options.registry.resolveContribution(
c.req.param("moduleId"),
c.req.param("version"),
c.req.param("contributionId"),
);
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
},
);
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
const published = options.registry.resolveDisplay(
c.req.param("moduleId"),
c.req.param("version"),
);
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
});
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
});
return routes;
}
+277 -65
View File
@@ -1,28 +1,53 @@
import type { Socket } from "node:net"; import type { Socket } from "node:net";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import type { ModuleRuntimeServices } from "@ekklesia/contracts"; import {
EMPTY_MODULE_SECRET_SOURCE,
type ModuleRuntimeServices,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import { import {
createAuthorizationEvaluator, createAuthorizationEvaluator,
createAuthorizationStore, createAuthorizationStore,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
import { import {
createIdentityNotifications,
createIdentityRuntime,
type IdentityNotificationReporter,
type IdentityRateLimitOverride,
} from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import {
createModuleConfigurationStore,
createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleEligibilityResolution,
resolveModuleEligibility,
type StartedModuleRuntime, type StartedModuleRuntime,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { serve, type ServerType } from "@hono/node-server"; import {
createModuleStorageLookup,
createS3StorageProvider,
type StorageProvider,
} from "@ekklesia/core/storage";
import { type ServerType, serve } from "@hono/node-server";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { createApiApp } from "./app.js"; import { createApiApp } from "./app.js";
import { import {
type ClientAddressConfigInput,
type ClientAddressResolver, type ClientAddressResolver,
createClientAddressResolver, createClientAddressResolver,
wrapFetchWithClientAddress, wrapFetchWithClientAddress,
} from "./client-address.js"; } from "./client-address.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { withDevicePrincipals } from "./device-principal.js";
import { loadDisplayHostAssets } from "./display-host-assets.js";
import { prepareStartupModuleInventory } from "./platform-permissions.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000; const connectionTimeoutMs = 10_000;
@@ -118,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;
@@ -137,6 +171,17 @@ export interface StartApiOptions {
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline /** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */ * (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
readonly moduleHookTimeoutMsOverride?: number; readonly moduleHookTimeoutMsOverride?: number;
/**
* Where deployment-supplied module secrets come from (ADR 0029) — the one *production* dependency
* in this options object rather than a test-only escape hatch.
*
* It is a dependency rather than a field on `ApiRuntimeConfig` because it is a lookup, not a
* validated value, and because the set of secrets depends on which modules are discovered — which
* is not known when configuration is validated. The executable entry point supplies an
* environment-backed source; omitting it supplies nothing, so a module declaring a required secret
* is reported `secret_missing` rather than silently starting without it.
*/
readonly moduleSecrets?: ModuleSecretSource;
} }
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> { async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
@@ -148,6 +193,31 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
} }
} }
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the mail provider's transport. */
async function safeCloseMail(provider: MailProvider | undefined): Promise<void> {
if (!provider) {
return;
}
try {
await provider.close();
} catch {
// See safeCloseDatabase: never mask a failure that is already propagating.
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own
* connection pool. */
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
if (!provider) {
return;
}
try {
await provider.close();
} catch {
// See safeCloseDatabase: never mask a failure that is already propagating.
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup /** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup
* failure that already occurred after the module runtime started — never during normal shutdown, * failure that already occurred after the module runtime started — never during normal shutdown,
* where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */ * where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */
@@ -319,12 +389,15 @@ function bindServer(
* facade each get a typed Drizzle view over it — see `./database.js`). * facade each get a typed Drizzle view over it — see `./database.js`).
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool, * 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
* then the Core identity runtime, authorization store, and evaluator. * then the Core identity runtime, authorization store, and evaluator.
* 4. Start the module runtime from that exact same discovered inventory * 4. Read the stored module enablement configuration and resolve, from that exact same discovered
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the * inventory, which modules are eligible to run (`resolveModuleEligibility`).
* same services object every module's `start`/`stop` hook receives. * 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
* 5. Create the Hono application, mounting the started module runtime's published routes and * rediscovering modules a second time — passing it the same services object every module's
* passing the exact same services object to every module HTTP handler. * `start`/`stop` hook receives.
* 6. Bind the Node HTTP server. * 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the exact same services object to every module HTTP
* handler.
* 7. Bind the Node HTTP server.
* *
* Discovery and permission composition happen before any pool, Better Auth instance, module * Discovery and permission composition happen before any pool, Better Auth instance, module
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid * entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
@@ -334,10 +407,20 @@ function bindServer(
* module database facade, identity, authorization) exists before any executable module lifecycle * module database facade, identity, authorization) exists before any executable module lifecycle
* code runs, and every module route is published only after the entire module runtime has * code runs, and every module route is published only after the entire module runtime has
* registered and started successfully, so no request can ever reach a module before every module * registered and started successfully, so no request can ever reach a module before every module
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point * has started. A module that is disabled, or blocked by a disabled required dependency, is never
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never * imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
* applies migrations, generates schema, or issues a module database query merely because modules * published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
* were discovered — the database must already be migrated through `pnpm migrations:apply`. * there is nothing mounted to check.
*
* Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration
* cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather
* than starting every discovered module: the platform cannot know what an administrator decided, and
* starting a module they disabled is worse than not starting at all.
*
* Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can
* stop the server, the module runtime, and the shared pool deterministically. Never applies
* migrations, generates schema, or issues a module database query merely because modules were
* discovered — the database must already be migrated through `pnpm migrations:apply`.
*/ */
export async function startApi( export async function startApi(
config: ApiRuntimeConfig, config: ApiRuntimeConfig,
@@ -361,38 +444,134 @@ export async function startApi(
transport: config.databaseTransport, transport: config.databaseTransport,
}); });
// Both host providers are constructed from validated configuration **before** anything that uses
// them, and neither touches the network while being built: an S3 client opens no connection and a
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
// `undefined` for both, which is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices; let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>; let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>; let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>;
try { try {
services = Object.freeze({ database: databaseHandle.module }); services = Object.freeze({ database: databaseHandle.module });
runtime = createIdentityRuntime({ runtime = createIdentityRuntime({
config: config.identity, config: config.identity,
db: databaseHandle.identity, db: databaseHandle.identity,
// Identity's two outbound messages, composed here — the one place that knows both the
// validated identity configuration and the host's mail provider. Core's identity code learns
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
notifications: createIdentityNotifications({
authBaseUrl: config.identity.baseUrl,
dashboardOrigin: config.identity.dashboardOrigin,
mail: mailProvider,
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
}),
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
}); });
authorizationStore = createAuthorizationStore(databaseHandle.authorization); authorizationStore = createAuthorizationStore(databaseHandle.authorization);
deviceStore = createDeviceStore(databaseHandle.devices);
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
const evaluator = createAuthorizationEvaluator({ const evaluator = createAuthorizationEvaluator({
// Device authority reaches the one evaluator here. There is no second decision point: a device
// is allowed or denied by the same function, with the same deny-by-default, as a person.
devices: deviceStore,
grants: authorizationStore, grants: authorizationStore,
platformOwners: authorizationStore, platformOwners: authorizationStore,
}); });
// Read the administrator's enablement decisions and resolve which discovered modules may actually
// start, before a single module entrypoint is imported. A failure here fails startup rather than
// falling back to "start everything": the platform cannot know what it was told, and starting a
// module an administrator disabled is worse than not starting at all.
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
const moduleConfigurationStore = createModuleConfigurationStore(
databaseHandle.moduleConfiguration,
);
const moduleSecrets = options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE;
let eligibility: ModuleEligibilityResolution;
let moduleConfigurationSettings: ModuleConfigurationSettings;
try {
// Both axes are read before a single module entrypoint is imported, and resolved together in
// one pass (ADR 0029): a module is eligible only if an administrator enabled it, its own
// required configuration is satisfiable, and every module it requires is eligible too. The pass
// also produces each eligible module's frozen configuration snapshot, so the configuration that
// made a module eligible is the identical object it later receives.
// Read sequentially rather than concurrently, deliberately: these are two tiny queries, and
// racing them would make the reported failure depend on which rejected first. An operator
// debugging a broken deployment must get the same error code every time.
const lifecycleConfiguration = await moduleLifecycleStore.readConfiguration();
const settings = await moduleConfigurationStore.readSettings();
moduleConfigurationSettings = settings;
eligibility = resolveModuleEligibility(
inventory.modules,
lifecycleConfiguration,
{ secrets: moduleSecrets, settings },
{ providerConfigured: storageProvider !== undefined },
);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
// One scoped facade per eligible module, over the exact buckets that module's own manifest
// declares. Built from the eligible set so a blocked module never receives storage at all.
const moduleStorages = createModuleStorageLookup(
storageProvider,
config.storage?.bucketPrefix ?? "",
eligibility.eligibleModules.map((module) => ({
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
moduleId: module.manifest.id,
})),
);
// This snapshot is inert metadata only: construction reads no artifact and executes no module.
// It lets the API prove that a display host build is available *before* importing any eligible
// module code. The snapshot is not exposed anywhere unless the complete runtime subsequently
// starts and application composition succeeds.
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
try {
displayHostAssets =
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
// Module load/register/start failures use Core's own existing rollback (already-started // Module load/register/start failures use Core's own existing rollback (already-started
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime // modules are stopped, in reverse order, before this rejects) — there is no separate runtime
// handle to stop here, only the pool this failure leaves open. // handle to stop here, only the pool this failure leaves open.
//
// Only eligible modules are started, and startup remains all-or-nothing across *that* set: an
// ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no
// routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves
// discovery's topological order, so a started module's required dependencies still precede it.
let moduleRuntime: StartedModuleRuntime; let moduleRuntime: StartedModuleRuntime;
try { try {
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, { moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
configurations: eligibility.configurations,
storages: moduleStorages,
...(options.moduleHookTimeoutMsOverride === undefined ...(options.moduleHookTimeoutMsOverride === undefined
? {} ? {}
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }), : { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
}); });
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -406,12 +585,58 @@ export async function startApi(
store: authorizationStore, store: authorizationStore,
}, },
moduleRoutes: moduleRuntime.routes, moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services, moduleServices: services,
runtime, // Built from the exact **eligible** set the runtime just started, never the raw discovered
// inventory, and exposed only after startup succeeded — so the published set is the running
// set. A disabled or dependency-blocked module therefore publishes no artifact, appears in no
// browser inventory, and appears in no resolved Dashboard navigation, by construction rather
// than by a filter somewhere downstream. Modules declaring no UI role are simply absent.
moduleUi,
...(displayHostAssets === undefined ? {} : { displayHostAssets }),
moduleLifecycle: {
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
// reports it actually started — read from the runtime rather than assumed equal to the
// eligible set.
snapshot: Object.freeze({
discoveredModules: inventory.modules,
eligibility: eligibility.entries,
startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)),
}),
store: moduleLifecycleStore,
},
moduleConfiguration: {
// The settings this process actually resolved from, so `restartRequired` is a comparison
// against what is running rather than a guess.
bootSettings: moduleConfigurationSettings,
discoveredModules: inventory.modules,
secrets: moduleSecrets,
snapshots: eligibility.configurations,
store: moduleConfigurationStore,
},
moduleStorage: {
providerConfigured: storageProvider !== undefined,
snapshots: moduleStorages,
},
// A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard),
devices: {
// `Secure` follows the deployment's own scheme, read from the already-validated identity
// base URL rather than from any request. Production is https by configuration policy, so a
// real deployment always gets a Secure device cookie; a plaintext development origin would
// silently drop one.
secureCookies: config.identity.baseUrl.startsWith("https://"),
store: deviceStore,
},
// Principal resolution, extended with managed devices. A human session still wins, and this
// is the only place a device credential ever becomes a principal.
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
trustedOrigins: config.identity.trustedOrigins, trustedOrigins: config.identity.trustedOrigins,
}); });
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -427,6 +652,8 @@ export async function startApi(
server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts); server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts);
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -436,7 +663,9 @@ export async function startApi(
// would previously get an already-resolved `undefined` immediately, before the listener had // would previously get an already-resolved `undefined` immediately, before the listener had
// actually drained or the pool had actually closed. Storing the promise itself means every // actually drained or the pool had actually closed. Storing the promise itself means every
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and // caller — concurrent or sequential — awaits the exact same drain-then-close operation and
// observes it complete (or fail) exactly once. Order matters, and is now additionally // observes it complete (or fail) exactly once. Realtime subscriptions are closed *before* HTTP
// drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every
// clean shutdown open until the forced-drain deadline. Order matters, and is now additionally
// load-bearing for the module database facade: the HTTP listener is drained and closed first so // load-bearing for the module database facade: the HTTP listener is drained and closed first so
// no new or in-flight request can reach a module handler while the module runtime is stopping; // no new or in-flight request can reach a module handler while the module runtime is stopping;
// then the module runtime stops — its stop hooks may still use `services.database`, which is // then the module runtime stops — its stop hooks may still use `services.database`, which is
@@ -449,6 +678,10 @@ export async function startApi(
const shutdown = (): Promise<void> => { const shutdown = (): Promise<void> => {
if (!shutdownPromise) { if (!shutdownPromise) {
shutdownPromise = (async () => { shutdownPromise = (async () => {
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
// completes: an in-flight module command may still commit and publish its resulting state.
// `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run.
moduleRuntime.realtime.closeSubscriptions();
const drainOutcome = await drainHttpServer(server, drainTimeoutMs); const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
let moduleStopError: unknown; let moduleStopError: unknown;
try { try {
@@ -456,6 +689,10 @@ export async function startApi(
} catch (error) { } catch (error) {
moduleStopError = error; moduleStopError = error;
} }
// After the module runtime has stopped, so a `stop` hook may still use its storage
// facade, and before the pool closes, matching the existing ordering discipline exactly.
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a // Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
// module-stop failure can never leave the pool open. Both failures are reported together, // module-stop failure can never leave the pool open. Both failures are reported together,
@@ -499,65 +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,
}, },
moduleSecrets: host.moduleSecrets,
}; };
} }
export async function main(): Promise<void> { export async function main(): Promise<void> {
const { environment, input } = readConfigFromEnvironment(); const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
const config = createApiRuntimeConfig(input, { environment }); const config = createApiRuntimeConfig(input, { environment });
const running = await startApi(config); const running = await startApi(config, { moduleSecrets });
const address = running.server.address(); const address = running.server.address();
const boundPort = address && typeof address === "object" ? address.port : config.port; const boundPort = address && typeof address === "object" ? address.port : config.port;
File diff suppressed because it is too large Load Diff
+361 -3
View File
@@ -12,9 +12,22 @@ import {
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js"; import {
type CreateApiAppAuthorizationOptions,
type CreateApiAppOptions,
createApiApp as createApiAppWithModules,
} from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test"; const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test"; const untrustedOrigin = "https://evil.example.test";
@@ -43,6 +56,19 @@ function fakeModuleServices(): ModuleRuntimeServices {
return Object.freeze({ database }); return Object.freeze({ database });
} }
/** No module publishes UI in these wiring tests, so the publication route is mounted with an
* empty registry built by Core's own factory — never a hand-written stub, so the route under test
* always sees the real policy object production hands it. */
function emptyModuleUiPublication(): ModuleUiPublicationRegistry {
return createModuleUiPublicationRegistry([]);
}
/** Most application-wiring cases exercise no modules. Keep that fact explicit in one test helper
* while production composition still requires the realtime registry with no implicit fallback. */
function createApiApp(options: Omit<CreateApiAppOptions, "moduleRealtime">) {
return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY });
}
/** A fully stubbed authorization dependency, denying everything by default — tests that need a /** A fully stubbed authorization dependency, denying everything by default — tests that need a
* specific decision or store behavior override the relevant method explicitly. The registry * specific decision or store behavior override the relevant method explicitly. The registry
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the * defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
@@ -70,11 +96,31 @@ function fakeAuthorization(overrides: {
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store }; return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
} }
function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
return createApiApp({
moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
}
describe("Ekklesia API Hono application", () => { describe("Ekklesia API Hono application", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => { it("mounts Better Auth exactly under /api/auth/*", async () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -84,14 +130,231 @@ describe("Ekklesia API Hono application", () => {
const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.text()).toBe("auth-ok"); expect(await response.json()).toEqual({ ok: true });
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
}); });
it("replaces only a successful email sign-in body without exposing Better Auth's token or user", async () => {
const nativeCookie =
"__Secure-better-auth.session_token=opaque-cookie-value; Path=/; HttpOnly; Secure; SameSite=Lax";
const nativeBody = JSON.stringify({
token: "javascript-visible-session-token",
user: { email: "[email protected]", id: "user_123", name: "Owner" },
});
const nativeResponse = new Response(nativeBody, {
headers: {
"Access-Control-Allow-Origin": trustedOrigin,
"Content-Length": "4096",
"Content-Type": "application/json",
"Set-Cookie": nativeCookie,
"X-Native-Auth-Header": "preserved",
},
status: 201,
statusText: "Signed In",
});
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(nativeResponse),
});
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/sign-in/email", {
headers: { origin: trustedOrigin },
method: "POST",
});
const visibleBody = await response.text();
expect(response.status).toBe(201);
expect(response.statusText).toBe("Signed In");
expect(JSON.parse(visibleBody)).toEqual({ ok: true });
expect(visibleBody).not.toContain("token");
expect(visibleBody).not.toContain("user");
expect(visibleBody).not.toContain("session");
expect(visibleBody).not.toContain("[email protected]");
expect(response.headers.get("set-cookie")).toBe(nativeCookie);
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("content-type")).toBe("application/json; charset=UTF-8");
expect(response.headers.get("content-length")).not.toBe("4096");
expect(response.headers.get("x-native-auth-header")).toBe("preserved");
expect(nativeResponse.bodyUsed).toBe(false);
});
it.each([
"/api/auth/%73ign-in/email",
"/api/auth/sign-in%2Femail",
])("normalizes the router-equivalent encoded sign-in path %s", async (path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
Response.json({
token: "javascript-visible-session-token",
user: { email: "[email protected]" },
}),
),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method: "POST" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
it("preserves a failed email sign-in response body and status unchanged", async () => {
const nativeFailure = JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" });
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
new Response(nativeFailure, {
headers: { "Content-Type": "application/json" },
status: 401,
statusText: "Unauthorized",
}),
),
});
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(401);
expect(response.statusText).toBe("Unauthorized");
expect(await response.text()).toBe(nativeFailure);
expect(response.headers.get("cache-control")).toBe("no-store");
});
it.each([
["a GET to the sign-in path", "GET", "/api/auth/sign-in/email"],
["a sign-out POST", "POST", "/api/auth/sign-out"],
["a trailing-slash sign-in POST", "POST", "/api/auth/sign-in/email/"],
])("leaves %s on Better Auth's native success contract", async (_label, method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(new Response("native-auth-body", { status: 200 })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(200);
expect(await response.text()).toBe("native-auth-body");
expect(response.headers.get("cache-control")).toBe("no-store");
});
describe("unused native session-token endpoints", () => {
const disabledRequests = [
["GET", "/api/auth/get-session"],
["POST", "/api/auth/get-session"],
["GET", "/api/auth/list-sessions"],
["POST", "/api/auth/update-session"],
["POST", "/api/auth/change-password"],
] as const;
it.each(
disabledRequests,
)("returns a fixed 404 for %s %s, including query and trailing-slash spellings, without invoking Better Auth", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(
Response.json({
session: { token: "javascript-visible-session-token" },
token: "another-session-token",
}),
),
});
const app = createAuthWiringApp(runtime);
for (const requestPath of [path, `${path}?include=session`, `${path}/`]) {
const response = await app.request(requestPath, {
headers: { origin: trustedOrigin },
method,
});
const body = await response.text();
expect(response.status).toBe(404);
expect(JSON.parse(body)).toEqual({ code: "NOT_FOUND" });
expect(body).not.toContain("token");
expect(body).not.toContain("session");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
}
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it.each([
["GET", "/api/auth/%67et-session"],
["GET", "/api/auth/%6Cist-sessions"],
["POST", "/api/auth/%75pdate-session"],
["POST", "/api/auth/change-password%2F"],
["GET", "/api/auth/get-session%2F"],
["GET", "/api/auth%2Fget-session"],
["GET", "/api/auth/get-session%"],
])("does not delegate the encoded or malformed spelling %s %s", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(Response.json({ session: { token: "exposed" } })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it("denies an oversized update-session body before the body limiter or Better Auth reads it", async () => {
const runtime = fakeRuntime();
const app = createAuthWiringApp(runtime);
const response = await app.request("/api/auth/update-session", {
body: "x",
headers: {
"content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1),
"content-type": "application/json",
},
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it.each([
["POST", "/api/auth/list-sessions"],
["GET", "/api/auth/update-session"],
["GET", "/api/auth/change-password"],
["GET", "/api/auth/list-sessions/export"],
])("leaves the neighbouring non-denied request %s %s on the native contract", async (method, path) => {
const runtime = fakeRuntime({
handleAuthRequest: vi
.fn()
.mockResolvedValue(new Response("native-method-response", { status: 200 })),
});
const app = createAuthWiringApp(runtime);
const response = await app.request(path, { method });
expect(response.status).toBe(200);
expect(await response.text()).toBe("native-method-response");
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
});
it("does not route requests outside /api/auth/* to Better Auth", async () => { it("does not route requests outside /api/auth/* to Better Auth", async () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -110,6 +373,11 @@ describe("Ekklesia API Hono application", () => {
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -130,6 +398,11 @@ describe("Ekklesia API Hono application", () => {
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -152,6 +425,11 @@ describe("Ekklesia API Hono application", () => {
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -174,6 +452,11 @@ describe("Ekklesia API Hono application", () => {
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -193,6 +476,11 @@ describe("Ekklesia API Hono application", () => {
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -214,6 +502,11 @@ describe("Ekklesia API Hono application", () => {
}); });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -223,7 +516,7 @@ describe("Ekklesia API Hono application", () => {
const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.text()).toBe("auth-ok"); expect(await response.json()).toEqual({ ok: true });
expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("cache-control")).toBe("no-store");
}); });
@@ -232,6 +525,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -252,6 +550,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -279,6 +582,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -307,6 +615,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime, runtime,
@@ -340,6 +653,11 @@ describe("Ekklesia API Hono application", () => {
it("has deterministic 404 behavior for an unknown route", async () => { it("has deterministic 404 behavior for an unknown route", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -355,6 +673,11 @@ describe("Ekklesia API Hono application", () => {
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => { it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -372,6 +695,11 @@ describe("Ekklesia API Hono application", () => {
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => { it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -388,6 +716,11 @@ describe("Ekklesia API Hono application", () => {
it("supports requests with no Origin header at all (native/server clients)", async () => { it("supports requests with no Origin header at all (native/server clients)", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -402,6 +735,11 @@ describe("Ekklesia API Hono application", () => {
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => { it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -423,6 +761,11 @@ describe("Ekklesia API Hono application", () => {
it("never wildcards Access-Control-Allow-Origin", async () => { it("never wildcards Access-Control-Allow-Origin", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -447,6 +790,11 @@ describe("Ekklesia API Hono application", () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false }); const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(), runtime: fakeRuntime(),
@@ -474,6 +822,11 @@ describe("Ekklesia API Hono application", () => {
}; };
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize }, evaluator: { authorize: evaluatorAuthorize },
@@ -492,6 +845,11 @@ describe("Ekklesia API Hono application", () => {
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => { it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
moduleUi: emptyModuleUiPublication(),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}), authorization: fakeAuthorization({}),
runtime: fakeRuntime(), runtime: fakeRuntime(),
+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"],
+638
View File
@@ -0,0 +1,638 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { DashboardConfiguration } from "@ekklesia/core/dashboard";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("33333333-3333-4333-8333-333333333333");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly name: string;
readonly withUi?: boolean;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 7,
name: fixture.name,
version: "1.0.0",
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
);
if (fixture.withUi !== false) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
);
}
}
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly configuration?: Partial<DashboardConfiguration>;
}
const defaultFixtures: readonly ModuleFixture[] = [
{ id: "video", name: "Video" },
{ id: "livestream", name: "Livestream" },
{ id: "calendar", name: "Calendar" },
];
async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dashboard-routes-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures ?? defaultFixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async () => false },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const store = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
interface NavigationEntry {
readonly name: string;
readonly module: {
readonly id: string;
readonly version: string;
readonly ui: { readonly url: string };
readonly api: { readonly baseUrl: string };
readonly permissions: readonly string[];
};
}
interface NavigationBody {
readonly groups: ReadonlyArray<{
readonly id: string;
readonly label: string;
readonly order: number;
readonly modules: readonly NavigationEntry[];
}>;
readonly ungrouped: readonly NavigationEntry[];
}
async function navigationOf(app: Hono): Promise<NavigationBody> {
const response = await app.request(DASHBOARD_NAVIGATION_PATH);
expect(response.status).toBe(200);
return (await response.json()) as NavigationBody;
}
/** An administrator: a platform owner, who passes the evaluator's owner bypass for the restricted
* manage permission exactly as in production. */
async function administrator(configuration: Partial<DashboardConfiguration> = {}) {
return install({ configuration, platformOwner: true, principal: signedIn() });
}
function mutate(app: Hono, method: "PUT" | "DELETE", url: string, body?: unknown) {
return app.request(url, {
...(body === undefined
? {}
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
headers: {
origin: trustedOrigin,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
method,
});
}
describe("Dashboard navigation — reading", () => {
it("shows every mountable module as ungrouped when nothing is configured", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
expect(navigation.groups).toEqual([]);
// A fresh installation is immediately usable: no initialization write, no manual database edit.
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Calendar",
"Livestream",
"Video",
]);
});
it("renders the module's own manifest name, never its id", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
expect(navigation.ungrouped.map((entry) => entry.name)).not.toContain("livestream");
expect(navigation.ungrouped.find((entry) => entry.module.id === "livestream")?.name).toBe(
"Livestream",
);
});
it("groups and orders exactly as the stored configuration says", async () => {
const { app } = await install({
configuration: {
groups: [
{ id: "planning", label: "Planning", sortOrder: 20 },
{ id: "media", label: "Media", sortOrder: 10 },
],
placements: [
{ groupId: "media", moduleId: "livestream", sortOrder: 20 },
{ groupId: "media", moduleId: "video", sortOrder: 10 },
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(
navigation.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
).toEqual([
["Media", ["Video", "Livestream"]],
["Planning", ["Calendar"]],
]);
expect(navigation.ungrouped).toEqual([]);
});
it("embeds the generic mount descriptor unchanged inside each entry", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
const livestream = navigation.ungrouped.find((entry) => entry.module.id === "livestream");
// Presentation wraps the host-neutral descriptor; it never rewrites it.
expect(livestream?.module).toEqual({
contributions: [],
api: { baseUrl: "/api/v1/modules/livestream" },
display: null,
id: "livestream",
permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
version: "1.0.0",
});
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]);
});
it("agrees exactly with the generic inventory about what is mountable", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
modules: ReadonlyArray<{ id: string }>;
};
const navigationModules = [
...navigation.groups.flatMap((group) => group.modules),
...navigation.ungrouped,
].map((entry) => entry.module);
// Both derive from one publication registry through one shared description, so they cannot
// disagree about what exists or about any URL.
expect(navigationModules.map((module) => module.id).sort()).toEqual(
inventory.modules.map((module) => module.id).sort(),
);
});
it("keeps the generic inventory free of presentation metadata", async () => {
const { app } = await install({ principal: signedIn() });
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
// The host-neutral contract a standalone host consumes must not learn Ekklesia has folders or
// display names. That layering is the whole reason navigation is a separate route.
for (const field of ["name", "label", "group", "order", "ungrouped"]) {
expect(body, field).not.toContain(`"${field}"`);
}
});
it("omits a module that is no longer installed, without breaking navigation", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [
{ groupId: "media", moduleId: "video", sortOrder: 10 },
{ groupId: "media", moduleId: "removed-module", sortOrder: 20 },
],
},
fixtures: [{ id: "video", name: "Video" }],
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules.map((entry) => entry.module.id)).toEqual(["video"]);
expect(JSON.stringify(navigation)).not.toContain("removed-module");
});
it("keeps a newly installed module visible alongside an existing arrangement", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
// Calendar and Livestream were never configured and are still reachable.
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Livestream"]);
});
it("excludes a module that ships no UI, exactly like the inventory does", async () => {
const { app } = await install({
fixtures: [
{ id: "video", name: "Video" },
{ id: "roster", name: "Roster", withUi: false },
],
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(JSON.stringify(navigation)).not.toContain("Roster");
});
it("leaks no filesystem path, package root, or manifest internal", async () => {
const { app } = await install({ principal: signedIn() });
const body = await (await app.request(DASHBOARD_NAVIGATION_PATH)).text();
expect(body).not.toContain(tmpdir());
expect(body).not.toContain("dist");
expect(body).not.toContain("@test/module-");
expect(body).not.toContain("ekklesia.module.json");
expect(body).not.toContain("entrypoints");
expect(body).not.toContain("manifestVersion");
});
it("is per-principal, uncached, and refused to anonymous callers", async () => {
const { app: anonymous } = await install();
const { app } = await install({ principal: signedIn() });
const denied = await anonymous.request(DASHBOARD_NAVIGATION_PATH);
const allowed = await app.request(DASHBOARD_NAVIGATION_PATH, {
headers: { origin: trustedOrigin },
});
expect(denied.status).toBe(403);
expect(await denied.text()).not.toContain("Livestream");
expect(allowed.headers.get("cache-control")).toBe("no-store");
const vary = allowed.headers.get("vary")?.toLowerCase() ?? "";
expect(vary).toContain("origin");
expect(vary).toContain("cookie");
});
});
describe("Dashboard navigation — organizing", () => {
it("creates a group and places a module, and both persist into the resolved view", async () => {
const { app } = await administrator();
expect(
(
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
})
).status,
).toBe(200);
expect(
(
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "media",
order: 10,
})
).status,
).toBe(200);
const navigation = await navigationOf(app);
expect(navigation.groups.map((group) => group.label)).toEqual(["Media"]);
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Video"]);
});
it("renames a group without moving anything", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }],
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Sunday Production",
order: 10,
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]).toMatchObject({ id: "media", label: "Sunday Production" });
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
});
it("moves a module between groups and changes only its placement", async () => {
const { app } = await administrator({
groups: [
{ id: "media", label: "Media", sortOrder: 10 },
{ id: "sunday-production", label: "Sunday Production", sortOrder: 5 },
],
placements: [
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
{ groupId: "media", moduleId: "video", sortOrder: 20 },
],
});
const before = await navigationOf(app);
const livestreamBefore = before.groups
.flatMap((group) => group.modules)
.find((entry) => entry.module.id === "livestream");
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "sunday-production",
order: 10,
});
const after = await navigationOf(app);
const livestreamAfter = after.groups
.flatMap((group) => group.modules)
.find((entry) => entry.module.id === "livestream");
// The one property this whole pass exists to guarantee: the module — its artifact URL, its API
// root, its permissions, its version, its name — is byte-identical across the move.
expect(livestreamAfter).toEqual(livestreamBefore);
expect(
after.groups.find((group) => group.id === "sunday-production")?.modules.map((e) => e.name),
).toEqual(["Livestream"]);
expect(after.groups.find((group) => group.id === "media")?.modules.map((e) => e.name)).toEqual([
"Video",
]);
});
it("ungroups a module deliberately", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
groupId: null,
order: 5,
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules).toEqual([]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Video",
"Calendar",
"Livestream",
]);
});
it("ungroups a deleted group's modules rather than losing them", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
});
const deleted = await mutate(app, "DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`);
const navigation = await navigationOf(app);
expect(deleted.status).toBe(204);
expect(navigation.groups).toEqual([]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Video",
"Calendar",
"Livestream",
]);
});
it("refuses a placement naming a group that does not exist", async () => {
const { app } = await administrator();
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
groupId: "not-a-group",
order: 10,
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "DASHBOARD_GROUP_NOT_FOUND" });
});
it("refuses a body carrying anything beyond the declared fields", async () => {
const { app } = await administrator();
const bodies: readonly unknown[] = [
{ label: "Media", order: 10, parentGroupId: "root" },
{ label: "Media", order: 10, ownerModuleId: "video" },
{ label: "Media" },
{ label: "", order: 10 },
{ label: "Media", order: -1 },
{ label: "Media", order: 1.5 },
{ label: 42, order: 10 },
[],
null,
];
for (const body of bodies) {
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, body);
expect(response.status, JSON.stringify(body)).toBe(400);
expect(await response.json(), JSON.stringify(body)).toEqual({ code: "INVALID_REQUEST" });
}
});
it("refuses a malformed group identifier in the path", async () => {
const { app } = await administrator();
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/Not%20Valid`, {
label: "Media",
order: 10,
});
expect(response.status).toBe(400);
});
});
describe("Dashboard navigation — authority", () => {
it("refuses organizing to a signed-in user without the manage permission", async () => {
// Reading works for any signed-in user; organizing does not.
const { app } = await install({ principal: signedIn() });
expect((await app.request(DASHBOARD_NAVIGATION_PATH)).status).toBe(200);
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("refuses organizing from an untrusted origin, before authorization even runs", async () => {
const { app } = await administrator();
const response = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
body: JSON.stringify({ label: "Media", order: 10 }),
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("refuses organizing to an anonymous caller", async () => {
const { app } = await install();
for (const [method, url, body] of [
["PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10 }],
["DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, undefined],
["PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 0 }],
] as const) {
const response = await mutate(app, method, url, body);
expect(response.status, `${method} ${url}`).toBe(403);
}
});
it("grants no module authority to an administrator who reorganizes the Dashboard", async () => {
// The platform owner holds `core.dashboard.navigation.manage` through the owner bypass. That
// says nothing about any module: the hints in the navigation view are still evaluated per
// module permission, and every module route stays enforced on its own.
const { app } = await administrator();
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "media",
order: 10,
});
const navigation = await navigationOf(app);
const livestream = navigation.groups[0]?.modules[0];
// The fixture modules declare no permissions at all, so an owner still holds none for them:
// moving a module into a folder cannot manufacture a permission hint, let alone a grant.
expect(livestream?.module.permissions).toEqual([]);
});
it("routes no method the navigation contract does not define", async () => {
const { app } = await administrator();
for (const method of ["POST", "PATCH"] as const) {
const response = await app.request(DASHBOARD_NAVIGATION_PATH, {
headers: { origin: trustedOrigin },
method,
});
expect(response.status, method).toBe(404);
}
});
});
@@ -0,0 +1,810 @@
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createAuthorizationDrizzleDatabase,
createInitialPlatformOwner,
} from "@ekklesia/core/authorization";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* Device pairing end to end, against real PostgreSQL and the real HTTP transport (ADR 0036).
*
* The whole point is that none of this is mocked. A screen with no identity asks to be paired, a
* real administrator signs in through the real login transport and approves it, the host issues a
* credential in a real `Set-Cookie`, and the resulting device opens a real display descriptor and a
* real realtime stream — and is then revoked and loses all of it. Anything short of that would
* prove that rows were written, not that a television in a foyer works.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
const trackedDatabases = new Set<string>();
const trackedDirectories: string[] = [];
let databaseCounter = 0;
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const ownerPassword = "correct horse battery staple";
const displayPermission = "announcements.slideshow.view";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function baseDatabaseUrl(): string {
const url = process.env[integrationDatabaseUrlEnvironment];
if (typeof url !== "string" || url.trim() === "") {
throw new IntegrationHarnessError("The integration database URL is not configured.");
}
return url;
}
function databaseUrl(name: string): string {
const url = new URL(baseDatabaseUrl());
url.pathname = `/${name}`;
return url.toString();
}
async function openAdministrativeClient(): Promise<Client> {
const client = new Client({ connectionString: baseDatabaseUrl() });
await client.connect();
return client;
}
async function safeQuery<T extends QueryResultRow>(
client: Client,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
try {
return (await client.query<T>(text, [...values])).rows;
} catch {
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function query<T extends QueryResultRow>(
url: string,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
const client = new Client({ connectionString: url });
await client.connect();
try {
return await safeQuery<T>(client, text, values);
} finally {
await client.end().catch(() => undefined);
}
}
/** A display-capable module: one display entrypoint, one restricted permission protecting it, and
* one realtime channel gated by that same permission. */
async function createDisplayModuleDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
trackedDirectories.push(directory);
const moduleDirectory = path.join(directory, "announcements");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: "@test/module-announcements",
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
server: "./dist/server/index.js",
},
id: "announcements",
manifestVersion: 7,
name: "Announcements fixture",
permissions: [
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: displayPermission,
},
],
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "announcements",
version: "1.0.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
},
start({ realtime }) {
realtime.publish("state", { slides: [] });
},
stop() {},
};\n`,
),
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
]);
return directory;
}
async function createMigratedDatabase(label: string): Promise<string> {
databaseCounter += 1;
const name =
`ekk_device_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
const admin = await openAdministrativeClient();
try {
await safeQuery(admin, `create database "${name}"`);
} finally {
await admin.end().catch(() => undefined);
}
trackedDatabases.add(name);
const url = databaseUrl(name);
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModules,
platformVersion: "0.1.0",
},
});
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModules, { force: true, recursive: true });
}
return url;
}
async function startPlatform(url: string, modulesDirectory: string): Promise<RunningApi> {
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
// The production posture: no public signup anywhere in this suite.
publicSignupEnabled: false,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "development" },
);
return startApi(config, {
modulesDirectoryOverride: modulesDirectory,
rateLimitOverride: { max: 1000, window: 1 },
});
}
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw new IntegrationHarnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
/** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-`
* whenever the configured base URL is https — which this harness's is. */
function cookiePair(response: Response, name: string): string | undefined {
for (const header of response.headers.getSetCookie()) {
const pair = header.split(";")[0]?.trim() ?? "";
const cookieName = pair.slice(0, Math.max(0, pair.indexOf("=")));
if (cookieName === name || cookieName.endsWith(name)) {
return pair;
}
}
return undefined;
}
function setCookieFor(response: Response, name: string): string | undefined {
return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`));
}
async function createOwner(url: string, origin: string): Promise<string> {
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
identityDb: identity.db,
},
{ email, name: "Platform Owner", password: ownerPassword },
);
} finally {
await Promise.all([authorization.close(), identity.close()]);
}
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password: ownerPassword }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const cookie = cookiePair(signIn, "better-auth.session_token");
if (signIn.status !== 200 || cookie === undefined) {
throw new IntegrationHarnessError("The platform owner could not sign in.");
}
return cookie;
}
interface Platform {
readonly api: RunningApi;
readonly origin: string;
readonly url: string;
readonly ownerCookie: string;
readonly close: () => Promise<void>;
}
async function platform(label: string): Promise<Platform> {
const url = await createMigratedDatabase(label);
const modulesDirectory = await createDisplayModuleDirectory();
const api = await startPlatform(url, modulesDirectory);
const origin = apiOrigin(api);
const ownerCookie = await createOwner(url, origin);
return {
api,
close: async () => {
await api.shutdown();
},
origin,
ownerCookie,
url,
};
}
/** The device's own browser: no human session, only whatever cookies the host has set on it. */
class DeviceBrowser {
#cookies = new Map<string, string>();
constructor(private readonly origin: string) {}
absorb(response: Response): void {
for (const header of response.headers.getSetCookie()) {
const pair = header.split(";")[0] ?? "";
const separator = pair.indexOf("=");
if (separator === -1) {
continue;
}
const name = pair.slice(0, separator).trim();
const value = pair.slice(separator + 1).trim();
if (value.length === 0 || /max-age=0/iu.test(header)) {
this.#cookies.delete(name);
} else {
this.#cookies.set(name, value);
}
}
}
header(): string {
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; ");
}
has(name: string): boolean {
return this.#cookies.has(name);
}
overwrite(name: string, value: string): void {
this.#cookies.set(name, value);
}
async request(path: string, init: RequestInit = {}): Promise<Response> {
const cookie = this.header();
const response = await fetch(`${this.origin}${path}`, {
...init,
headers: {
origin: dashboardOrigin,
...(cookie.length > 0 ? { cookie } : {}),
...(init.headers as Record<string, string> | undefined),
},
});
this.absorb(response);
return response;
}
post(path: string, body: unknown): Promise<Response> {
return this.request(path, {
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
method: "POST",
});
}
}
function adminRequest(
origin: string,
cookie: string,
path: string,
body?: unknown,
overrides: RequestInit = {},
): Promise<Response> {
return fetch(`${origin}${path}`, {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
headers: {
cookie,
origin: dashboardOrigin,
...(body === undefined ? {} : { "content-type": "application/json" }),
...(overrides.headers as Record<string, string> | undefined),
},
method: body === undefined ? "GET" : "POST",
...overrides,
});
}
/** Drives a screen from "nobody has paired me" to "I hold a device session". */
async function pairDevice(
platform: Platform,
device: DeviceBrowser,
deviceName = "Foyer TV",
): Promise<{ readonly code: string }> {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
if (requested.status !== 201) {
throw new IntegrationHarnessError("The pairing request was refused.");
}
const { code } = (await requested.json()) as { code: string };
const approved = await adminRequest(
platform.origin,
platform.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName },
);
if (approved.status !== 200) {
throw new IntegrationHarnessError("The pairing approval was refused.");
}
const claimed = await device.post("/api/v1/device-pairing/claims", {});
if (((await claimed.json()) as { status: string }).status !== "paired") {
throw new IntegrationHarnessError("The device was not issued a session.");
}
return { code };
}
afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined);
if (admin) {
try {
for (const name of trackedDatabases) {
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
() => undefined,
);
}
} finally {
await admin.end().catch(() => undefined);
}
}
for (const directory of trackedDirectories.splice(0)) {
await rm(directory, { force: true, recursive: true }).catch(() => undefined);
}
});
describe.sequential("display device pairing, end to end", () => {
it("takes an unpaired screen all the way to a working display, and back off again", async () => {
const running = await platform("e2e");
const device = new DeviceBrowser(running.origin);
try {
// 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it
// needs in order to show a code is served.
expect((await device.request("/display/announcements")).status).toBe(200);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
expect(requested.status).toBe(201);
const body = (await requested.json()) as Record<string, unknown>;
expect(body.code).toMatch(/^[0-9]{6}$/u);
// The claim secret is never in the body — only in a cookie script cannot read.
expect(JSON.stringify(body)).not.toContain("claim");
const claimCookie = setCookieFor(requested, "ekklesia_device_pairing");
expect(claimCookie).toMatch(/HttpOnly/iu);
// 3. Nothing has happened yet: polling says pending, and the display is still refused.
expect(
(
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
status: string;
}
).status,
).toBe("pending");
// 4. The administrator looks the code up, sees what it wants, and approves it by name.
const lookup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/lookups",
{ code: body.code },
);
expect(lookup.status).toBe(200);
expect(await lookup.json()).toMatchObject({
moduleId: "announcements",
permissionId: displayPermission,
});
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code: body.code, deviceName: "Foyer TV" },
)
).status,
).toBe(200);
// 5. The screen collects its credential — through Set-Cookie, never through JSON.
const claimed = await device.post("/api/v1/device-pairing/claims", {});
expect(await claimed.json()).toEqual({ status: "paired" });
const sessionCookie = setCookieFor(claimed, "ekklesia_device_session");
expect(sessionCookie).toMatch(/HttpOnly/iu);
expect(sessionCookie).toMatch(/SameSite=Strict/iu);
expect(sessionCookie).toMatch(/Path=\//u);
expect(sessionCookie).not.toMatch(/Domain=/iu);
// The spent pairing cookie is cleared rather than left on the device.
expect(device.has("ekklesia_device_pairing")).toBe(false);
expect(device.has("ekklesia_device_session")).toBe(true);
// 6. The display now works, and reports the device as its viewer.
const descriptor = await device.request("/api/v1/module-displays/announcements");
expect(descriptor.status).toBe(200);
const descriptorBody = (await descriptor.json()) as {
viewer: { kind: string; deviceId?: string };
module: { permissions: string[] };
};
expect(descriptorBody.viewer.kind).toBe("device");
expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u);
// Exactly the one permission it was paired for.
expect(descriptorBody.module.permissions).toEqual([displayPermission]);
// 7. Realtime, through the ordinary permission-gated channel.
const stream = await device.request("/api/v1/module-realtime/announcements/state", {
headers: { accept: "text/event-stream" },
});
expect(stream.status).toBe(200);
expect(stream.headers.get("content-type")).toContain("text/event-stream");
await stream.body?.cancel();
// 8. The device is still nobody as far as the Dashboard is concerned.
expect(await (await device.request("/api/v1/principal")).json()).toEqual({
kind: "anonymous",
});
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
expect((await device.request("/api/v1/devices")).status).toBe(403);
// 9. The administrator revokes it.
const devices = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
expect(devices.devices).toHaveLength(1);
expect(devices.devices[0]?.name).toBe("Foyer TV");
expect(devices.devices[0]?.permissions).toEqual([displayPermission]);
const revoked = await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${devices.devices[0]?.id}/revoke`,
{},
);
expect(revoked.status).toBe(204);
// 10. Everything it could do, it can no longer do — while still holding its cookie.
expect(device.has("ekklesia_device_session")).toBe(true);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
expect(
(
await device.request("/api/v1/module-realtime/announcements/state", {
headers: { accept: "text/event-stream" },
})
).status,
).toBe(403);
// And the screen can start over: the page it needs for a new code is still served.
expect((await device.request("/display/announcements")).status).toBe(200);
} finally {
await running.close();
}
});
it("stores no usable credential, and puts none in any response body", async () => {
const running = await platform("secrets");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
expect(credential.length).toBeGreaterThan(20);
const stored = await query<{ credential_hash: string; name: string }>(
running.url,
`select credential_hash, name from ekklesia_core_device`,
);
expect(stored).toHaveLength(1);
// The raw credential is nowhere in the database — only an irreversible digest of it.
expect(stored[0]?.credential_hash).not.toContain(credential);
expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u);
const everything = JSON.stringify(
await query(running.url, `select * from ekklesia_core_device_pairing_request`),
);
expect(everything).not.toContain(credential);
// The descriptor a module's UI can read carries no credential either.
const descriptor = await device.request("/api/v1/module-displays/announcements");
expect(await descriptor.text()).not.toContain(credential);
} finally {
await running.close();
}
});
describe("pairing cannot be talked into anything", () => {
it("derives the permission from the display, never from the request", async () => {
const running = await platform("derive");
const device = new DeviceBrowser(running.origin);
try {
// The browser tries to name its own permission and its own module.
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
permission: "core.devices.manage",
permissionId: "core.devices.manage",
});
expect(requested.status).toBe(201);
const { code } = (await requested.json()) as { code: string };
const lookup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/lookups",
{ code },
);
// The host recorded the display's own declared permission and ignored the rest.
expect(await lookup.json()).toMatchObject({ permissionId: displayPermission });
const stored = await query<{ permission_id: string }>(
running.url,
`select permission_id from ekklesia_core_device_pairing_request`,
);
expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]);
} finally {
await running.close();
}
});
it("refuses to start pairing for a module that has no display", async () => {
const running = await platform("nodisplay");
const device = new DeviceBrowser(running.origin);
try {
for (const moduleId of ["calendar", "core", "../announcements", ""]) {
const response = await device.post("/api/v1/device-pairing/requests", { moduleId });
expect(response.status, moduleId).toBeGreaterThanOrEqual(400);
}
expect(
await query(running.url, `select id from ekklesia_core_device_pairing_request`),
).toHaveLength(0);
} finally {
await running.close();
}
});
it("issues nothing to someone who knows only the code", async () => {
const running = await platform("codeonly");
const device = new DeviceBrowser(running.origin);
const onlooker = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{
code,
deviceName: "Foyer TV",
},
);
// Someone who read the code off the television — and even guessed the request exists —
// holds no claim cookie, so there is nothing for them to collect.
const stolen = await onlooker.post("/api/v1/device-pairing/claims", {});
expect(stolen.status).toBe(400);
expect(onlooker.has("ekklesia_device_session")).toBe(false);
// The real screen still gets its credential.
const claimed = await device.post("/api/v1/device-pairing/claims", {});
expect(await claimed.json()).toEqual({ status: "paired" });
} finally {
await running.close();
}
});
});
describe("approval is an administrator's act", () => {
it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => {
const running = await platform("approve");
const device = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
// Anonymous.
const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, {
body: JSON.stringify({ code, deviceName: "Intruder TV" }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
expect(anonymous.status).toBe(403);
// The paired-device cookie is not an administrator either — a device can never approve
// another device, or itself.
const selfApproval = await device.post("/api/v1/device-pairing/approvals", {
code,
deviceName: "Self TV",
});
expect(selfApproval.status).toBe(403);
// A real administrator from an untrusted origin: CSRF policy applies before anything else.
const crossOrigin = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Foyer TV" },
{ headers: { origin: untrustedOrigin } },
);
expect(crossOrigin.status).toBe(403);
// Nothing was approved by any of that.
expect(
(
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
status: string;
}
).status,
).toBe("pending");
} finally {
await running.close();
}
});
it("cannot approve the same code twice, and cannot mint two credentials from one", async () => {
const running = await platform("single");
const device = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Foyer TV" },
)
).status,
).toBe(200);
// A second approval of the same code finds nothing pending.
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Second TV" },
)
).status,
).toBe(404);
// Two claims race for one approved request; exactly one credential may exist.
const [first, second] = await Promise.all([
device.post("/api/v1/device-pairing/claims", {}),
device.post("/api/v1/device-pairing/claims", {}),
]);
const statuses = [
((await first.json()) as { status: string }).status,
((await second.json()) as { status: string }).status,
];
expect(statuses.filter((status) => status === "paired")).toHaveLength(1);
expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1);
} finally {
await running.close();
}
});
});
it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => {
const running = await platform("repair");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device, "Foyer TV");
const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string }> };
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
await pairDevice(running, device, "Foyer TV (replaced)");
const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
expect(secondCredential).not.toBe(firstCredential);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
// Re-pairing creates a new identity rather than reviving the revoked one, so the old
// credential can never come back — presenting it again is simply anonymous.
const revokedBrowser = new DeviceBrowser(running.origin);
revokedBrowser.overwrite("ekklesia_device_session", firstCredential);
expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe(
403,
);
const devices = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ revoked: boolean }> };
expect(devices.devices).toHaveLength(2);
expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1);
} finally {
await running.close();
}
});
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const deviceCredential = device.header();
// A browser presenting both: the person still wins, and stays a person.
const both = await fetch(`${running.origin}/api/v1/principal`, {
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
});
expect(((await both.json()) as { kind: string }).kind).toBe("authenticated");
// And that administrator can still open the display for testing, as a human.
const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, {
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
});
expect(descriptor.status).toBe(200);
expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe(
"authenticated",
);
} finally {
await running.close();
}
});
});
@@ -0,0 +1,84 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const readFileMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
vi.mock("@ekklesia/shell/display-assets", () => ({
displayHostAssetUrls: Object.freeze({
html: "file:///surface/browser/display/index.html",
script: "file:///surface/browser/display/app.js",
serviceWorker: "file:///surface/browser/display/sw.js",
}),
}));
import {
createDisplayHostAssets,
DisplayHostAssetError,
loadDisplayHostAssets,
} from "../src/display-host-assets.js";
describe("display host asset loading", () => {
beforeEach(() => readFileMock.mockReset());
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
const html = '<!doctype html><div id="ekklesia-display"></div>';
const script = "export const displayHost = true;\n";
const serviceWorker = "export const displayWorker = true;\n";
readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce(script)
.mockResolvedValueOnce(serviceWorker);
const assets = await loadDisplayHostAssets();
expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/display/index.html"), "utf8"],
[new URL("file:///surface/browser/display/app.js"), "utf8"],
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
]);
expect(assets).toEqual({
html,
script,
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
serviceWorker,
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
});
expect(Object.isFrozen(assets)).toBe(true);
});
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
readFileMock.mockRejectedValueOnce(
new Error("ENOENT /home/operator/private/surface-build secret-token"),
);
const failure = await loadDisplayHostAssets().catch((error: unknown) => error);
expect(readFileMock).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(DisplayHostAssetError);
expect(failure).toMatchObject({
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
message: "The display host browser assets are unavailable.",
name: "DisplayHostAssetError",
});
expect((failure as Error).stack).toBe(
"DisplayHostAssetError: The display host browser assets are unavailable.",
);
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
expect(publicText).not.toContain(secret);
}
});
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
for (const [html, script, serviceWorker] of [
["", "export {};", "export {};"],
["<html></html>", " \n", "export {};"],
["<html></html>", "export {};", " \n"],
] as const) {
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
DisplayHostAssetError,
);
}
});
});
@@ -0,0 +1,816 @@
import { createHmac, randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
type IdentityNotificationFailure,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { SMTPServer } from "smtp-server";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* Email verification and password recovery, end to end (ADR 0038).
*
* Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better
* Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The
* verification and reset links are **extracted from the delivered message** and then followed, which
* is the only way to prove the whole path rather than the pieces — a fake mail function would prove
* that a callback was invoked and nothing about whether the resulting link works.
*
* `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the
* internet and no real email is ever sent.
*
* Public signup is disabled in every scenario, matching production. Accounts that are not the
* bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses,
* which is deliberately not reachable over HTTP.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
const dashboardOrigin = "http://dashboard.localhost:5173";
const integrationSecret = "integration-test-secret-0123456789abcdefgh";
const initialPassword = "correct horse battery staple";
const replacementPassword = "an entirely different passphrase";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function baseDatabaseUrl(): string {
const url = process.env[integrationDatabaseUrlEnvironment];
if (typeof url !== "string" || url.trim() === "") {
throw new IntegrationHarnessError("The integration database URL is not configured.");
}
return url;
}
function databaseUrl(name: string): string {
const url = new URL(baseDatabaseUrl());
url.pathname = `/${name}`;
return url.toString();
}
async function openAdministrativeClient(): Promise<Client> {
const client = new Client({ connectionString: baseDatabaseUrl() });
await client.connect();
return client;
}
async function safeQuery<T extends QueryResultRow>(
client: Client,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
try {
const result = await client.query<T>(text, [...values]);
return result.rows;
} catch {
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModules,
platformVersion: "0.1.0",
},
});
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModules, { force: true, recursive: true });
}
}
async function freshInstallation(label: string): Promise<string> {
databaseCounter += 1;
const name =
`ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
const admin = await openAdministrativeClient();
try {
await safeQuery(admin, `create database "${name}"`);
} finally {
await admin.end().catch(() => undefined);
}
trackedDatabases.add(name);
const url = databaseUrl(name);
await applyCoreMigrations(url);
return url;
}
/** A delivered message, as the SMTP server actually received it. */
interface DeliveredMessage {
readonly to: readonly string[];
readonly data: string;
}
interface MailHarness {
readonly port: number;
readonly delivered: DeliveredMessage[];
readonly close: () => Promise<void>;
}
const openResources: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const close of openResources.splice(0).reverse()) {
await close().catch(() => undefined);
}
});
/**
* Picks a free port before the API is configured.
*
* The identity base URL is what the emailed link is built from, so it has to be known *before*
* `startApi` binds. Without this the test could only assert the link's shape and would have to
* rewrite its host before following it — which is exactly the part worth proving. Reserving and
* releasing a port leaves a theoretical race, but nothing else on the loopback interface is
* competing for ephemeral ports inside this suite.
*/
async function reservePort(): Promise<number> {
const { createServer } = await import("node:net");
const server = createServer();
const port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address() as AddressInfo;
resolve(address.port);
});
});
await new Promise<void>((resolve) => server.close(() => resolve()));
return port;
}
async function startMailServer(): Promise<MailHarness> {
const delivered: DeliveredMessage[] = [];
const server = new SMTPServer({
authOptional: true,
disabledCommands: ["STARTTLS"],
onData(stream, session, callback) {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString("utf8");
});
stream.on("end", () => {
delivered.push({
data,
to: session.envelope.rcptTo.map((recipient) => recipient.address),
});
callback();
});
},
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.server.address() as AddressInfo;
const harness: MailHarness = {
close: async () =>
await new Promise<void>((resolve) => {
server.close(() => resolve());
}),
delivered,
port: address.port,
};
openResources.push(harness.close);
return harness;
}
interface Installation {
readonly url: string;
readonly api: RunningApi;
readonly origin: string;
readonly mail: MailHarness | undefined;
readonly notificationFailures: IdentityNotificationFailure[];
readonly createIdentity: (input: {
readonly email: string;
readonly password: string;
readonly name: string;
}) => Promise<void>;
}
/**
* A migrated installation with the API running, optionally with a working SMTP relay.
*
* `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the
* development environment; production configuration refuses both, which `identity-config` and
* `mail-contract` assert separately.
*/
async function installation(
label: string,
options: { readonly mail?: boolean } = {},
): Promise<Installation> {
const url = await freshInstallation(label);
const mail = options.mail === false ? undefined : await startMailServer();
const notificationFailures: IdentityNotificationFailure[] = [];
const identity = createIdentityDrizzleDatabase(url, {});
openResources.push(async () => {
await identity.close().catch(() => undefined);
});
const apiPort = await reservePort();
const identityBaseUrl = `http://127.0.0.1:${apiPort}`;
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
dashboardOrigin,
publicSignupEnabled: false,
secret: integrationSecret,
trustedOrigins: [dashboardOrigin],
},
...(mail
? {
mail: {
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "127.0.0.1",
port: String(mail.port),
security: "plaintext",
},
}
: {}),
port: apiPort,
},
{ environment: "development" },
);
const api = await startApi(config, {
rateLimitOverride: { max: 1000, window: 1 },
reportNotificationFailure: (failure) => notificationFailures.push(failure),
});
openResources.push(async () => {
await api.shutdown().catch(() => undefined);
});
const address = api.server.address();
if (!address || typeof address !== "object" || address.port !== apiPort) {
throw new IntegrationHarnessError("The API integration server did not bind the reserved port.");
}
return {
api,
createIdentity: createBootstrapIdentityCreator(identity.db),
mail,
notificationFailures,
origin: identityBaseUrl,
url,
};
}
async function post(
origin: string,
path: string,
body: unknown,
headers: Readonly<Record<string, string>> = {},
): Promise<Response> {
return await fetch(`${origin}${path}`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
redirect: "manual",
});
}
function firstCookiePair(response: Response): string | undefined {
const setCookie = response.headers.get("set-cookie");
return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim();
}
async function signIn(
origin: string,
email: string,
password: string,
): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> {
const response = await post(origin, "/api/auth/sign-in/email", { email, password });
return { cookiePair: firstCookiePair(response), response };
}
async function principalKind(origin: string, cookiePair: string | undefined): Promise<string> {
const response = await fetch(`${origin}/api/v1/principal`, {
headers: cookiePair ? { cookie: cookiePair } : {},
});
const body = (await response.json()) as { readonly kind?: unknown };
return String(body.kind);
}
/**
* The one place a test reads a token, and only ever out of a message this suite's own SMTP server
* received. Nothing in the platform exposes one.
*
* Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by
* the encoder, and reassembling it here is what makes the extracted link the one a mail client would
* actually resolve.
*/
function extractLink(message: DeliveredMessage, contains: string): URL {
const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", "");
const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
);
const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains));
if (!match) {
throw new IntegrationHarnessError(`No ${contains} link was delivered.`);
}
return new URL(match);
}
function lastMessage(mail: MailHarness): DeliveredMessage {
const message = mail.delivered.at(-1);
if (!message) {
throw new IntegrationHarnessError("No message was delivered.");
}
return message;
}
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ email_verified: boolean }>(
client,
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
[email.toLowerCase()],
);
return rows[0]?.email_verified;
} finally {
await client.end().catch(() => undefined);
}
}
/**
* An account that has completed the real verification round trip.
*
* Recovery is only meaningful for a verified account, and building one through the actual flow —
* rather than by writing `email_verified` directly — keeps every recovery test standing on a
* verification path that genuinely works.
*/
async function verifiedAccountFor(install: Installation, email: string): Promise<void> {
await install.createIdentity({ email, name: "Member", password: initialPassword });
await signIn(install.origin, email, initialPassword);
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
await fetch(link, { redirect: "manual" });
(install.mail as MailHarness).delivered.length = 0;
}
afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined);
if (!admin) {
return;
}
try {
for (const name of trackedDatabases) {
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
() => undefined,
);
}
} finally {
await admin.end().catch(() => undefined);
}
});
/**
* Forges a verification token that is structurally valid but already expired.
*
* Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned
* release) — not a database row — so an expired one can be produced here from the same secret this
* suite configured, with no library internals and no waiting an hour. This is the only place any
* test constructs a token rather than reading one out of a delivered message.
*/
function forgeExpiredVerificationToken(email: string): string {
const encode = (value: object): string =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const issued = Math.floor(Date.now() / 1000) - 7200;
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
email: email.toLowerCase(),
exp: issued + 3600,
iat: issued,
})}`;
const signature = createHmac("sha256", integrationSecret)
.update(signingInput)
.digest("base64url");
return `${signingInput}.${signature}`;
}
describe("email verification, end to end", () => {
it("takes a new human account from unverified to signed in through a real emailed link", async () => {
const install = await installation("flow");
const email = "[email protected]";
await install.createIdentity({ email, name: "Member", password: initialPassword });
// 1. Unverified. A correct password does not produce a session — the identity system refuses
// before creating one, so there is no half-authenticated state anywhere (ADR 0038).
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
const refused = await signIn(install.origin, email, initialPassword);
expect(refused.response.status).toBe(403);
expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
expect(refused.cookiePair).toBeUndefined();
expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous");
// 2. That attempt delivered a verification message through the real SMTP provider.
const mail = install.mail as MailHarness;
expect(mail.delivered).toHaveLength(1);
expect(lastMessage(mail).to).toEqual([email]);
expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address");
// 3. Follow the link exactly as it was delivered. Nothing rewrites it.
const link = extractLink(lastMessage(mail), "/verify-email");
expect(link.origin).toBe(install.origin);
const verified = await fetch(link, { redirect: "manual" });
// 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the
// API origin, and never to anything a request supplied.
expect(verified.status).toBe(302);
const landing = new URL(verified.headers.get("location") ?? "");
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email");
expect(landing.searchParams.get("error")).toBeNull();
// No session was minted by following an emailed link.
expect(verified.headers.get("set-cookie")).toBeNull();
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
// 5. The same credentials now work, and the principal is a fully authenticated human.
const accepted = await signIn(install.origin, email, initialPassword);
expect(accepted.response.status).toBe(200);
expect(accepted.cookiePair).toBeDefined();
expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated");
});
it("refuses a malformed token and an expired token, each with its own reason", async () => {
const install = await installation("bad-token");
const email = "[email protected]";
await install.createIdentity({ email, name: "Expired", password: initialPassword });
const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`);
for (const [token, reason] of [
["not-a-jwt", "INVALID_TOKEN"],
[forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"],
] as const) {
const response = await fetch(
`${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`,
{ redirect: "manual" },
);
expect(response.status).toBe(302);
const landing = new URL(response.headers.get("location") ?? "");
// The failure still lands on the host's own screen rather than a raw JSON error page.
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("error")).toBe(reason);
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
}
});
it("is idempotent when a link is followed twice, because the token is a signed claim", async () => {
const install = await installation("replay");
const email = "[email protected]";
await install.createIdentity({ email, name: "Replay", password: initialPassword });
await signIn(install.origin, email, initialPassword);
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
const first = await fetch(link, { redirect: "manual" });
const second = await fetch(link, { redirect: "manual" });
// Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed
// database row, so it is **not** single-use — it is valid until it expires. Replaying it is a
// no-op on an already-verified address and mints no session, which is what makes that safe.
// Mail scanners and prefetchers dereference links, so idempotence is a requirement here.
expect(first.status).toBe(302);
expect(second.status).toBe(302);
expect(second.headers.get("set-cookie")).toBeNull();
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
});
it("resends on request without revealing whether the address has an account", async () => {
const install = await installation("resend");
const email = "[email protected]";
await install.createIdentity({ email, name: "Resend", password: initialPassword });
const mail = install.mail as MailHarness;
const known = await post(install.origin, "/api/auth/send-verification-email", { email });
const unknown = await post(install.origin, "/api/auth/send-verification-email", {
email: "[email protected]",
});
const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", {
email: "[email protected]",
});
// Identical answers. The only difference anywhere is that a message was delivered, which the
// requester cannot observe.
expect(known.status).toBe(200);
expect(unknown.status).toBe(200);
expect(verifiedAlready.status).toBe(200);
expect(await known.clone().json()).toEqual(await unknown.clone().json());
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
});
});
describe("password recovery, end to end", () => {
it("replaces the password through a real emailed link and invalidates the old one", async () => {
const install = await installation("reset");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const mail = install.mail as MailHarness;
const requested = await post(install.origin, "/api/auth/request-password-reset", { email });
expect(requested.status).toBe(200);
expect(mail.delivered).toHaveLength(1);
expect(lastMessage(mail).to).toEqual([email]);
expect(lastMessage(mail).data).toContain("Reset your Ekklesia password");
// Follow the emailed link. Better Auth validates the token and hands the browser on to the
// Dashboard landing with it attached — which is where the host's reset form picks it up.
const link = extractLink(lastMessage(mail), "/reset-password/");
expect(link.origin).toBe(install.origin);
const handoff = await fetch(link, { redirect: "manual" });
expect(handoff.status).toBe(302);
const landing = new URL(handoff.headers.get("location") ?? "");
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password");
const token = landing.searchParams.get("token") ?? "";
expect(token).not.toBe("");
const completed = await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
expect(completed.status).toBe(200);
const oldCredential = await signIn(install.origin, email, initialPassword);
expect(oldCredential.response.status).toBe(401);
const newCredential = await signIn(install.origin, email, replacementPassword);
expect(newCredential.response.status).toBe(200);
expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated");
});
it("consumes the reset token, so a captured link cannot be replayed", async () => {
const install = await installation("reset-replay");
const email = "[email protected]";
await verifiedAccountFor(install, email);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
const first = await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
const second = await post(install.origin, "/api/auth/reset-password", {
newPassword: "yet another different passphrase",
token,
});
// Unlike the verification token, this one is a database row Better Auth consumes.
expect(first.status).toBe(200);
expect(second.status).toBe(400);
expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" });
expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200);
});
it("signs out every existing session for that account", async () => {
const install = await installation("reset-sessions");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const before = await signIn(install.origin, email, initialPassword);
expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated");
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
// The whole point of recovery is that it works when the credential is already compromised, so
// an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`).
expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous");
});
it("enforces the identity system's own password policy, with no reset-specific rule", async () => {
const install = await installation("reset-policy");
const email = "[email protected]";
await verifiedAccountFor(install, email);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
const tooShort = await post(install.origin, "/api/auth/reset-password", {
newPassword: "short",
token,
});
expect(tooShort.status).toBe(400);
expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" });
// The old password still works: a rejected reset changes nothing.
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200);
});
it("answers a request for an address with no account exactly as it answers a real one", async () => {
const install = await installation("enumeration");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const mail = install.mail as MailHarness;
const known = await post(install.origin, "/api/auth/request-password-reset", { email });
const unknown = await post(install.origin, "/api/auth/request-password-reset", {
email: "[email protected]",
});
expect(known.status).toBe(unknown.status);
expect(await known.clone().json()).toEqual(await unknown.clone().json());
expect(JSON.stringify(await unknown.json())).not.toContain("stranger");
// Only the real account received anything.
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
});
});
describe("recovery when the installation has no outbound email", () => {
it("answers identically and records the operational failure out of band", async () => {
const install = await installation("no-mail", { mail: false });
const email = "[email protected]";
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
const reset = await post(install.origin, "/api/auth/request-password-reset", { email });
const resend = await post(install.origin, "/api/auth/send-verification-email", { email });
// The user-facing answer must not change: "we could not send it" for a real address and a
// generic success for an unknown one is an enumeration oracle that appears when SMTP breaks.
expect(reset.status).toBe(200);
expect(resend.status).toBe(200);
// The operator still learns about it, with a sanitized code and no address or token.
expect(install.notificationFailures).toEqual([
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
]);
expect(JSON.stringify(install.notificationFailures)).not.toContain("[email protected]");
});
it("still starts, serves the API, and refuses an unverified sign-in", async () => {
const install = await installation("no-mail-runs", { mail: false });
const email = "[email protected]";
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
// Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification
// policy — which is exactly why the initial owner is created already verified.
expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200);
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403);
});
});
describe("recovery cannot be turned into a redirect or an account factory", () => {
it("refuses an attacker-supplied reset destination", async () => {
const install = await installation("redirect");
const email = "[email protected]";
await install.createIdentity({ email, name: "Redirect", password: initialPassword });
const external = await post(install.origin, "/api/auth/request-password-reset", {
email,
redirectTo: "https://evil.example.test/steal",
});
expect(external.status).toBe(403);
expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" });
expect((install.mail as MailHarness).delivered).toHaveLength(0);
});
it("refuses an attacker-supplied verification destination", async () => {
const install = await installation("redirect-verify");
for (const callbackURL of [
"https://evil.example.test",
"//evil.example.test",
"/\\evil.example.test",
"javascript:alert(1)",
]) {
const response = await fetch(
`${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`,
{ redirect: "manual" },
);
expect(response.status).toBe(403);
expect(response.headers.get("location")).toBeNull();
}
});
it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => {
const install = await installation("redirect-ignored");
const email = "[email protected]";
await verifiedAccountFor(install, email);
// A *trusted* origin passes Better Auth's own check, so this is the case where the emailed
// link's destination would otherwise be caller-chosen. It is not: the message is built from
// configuration and the supplied value never reaches it.
await post(install.origin, "/api/auth/request-password-reset", {
email,
redirectTo: `${dashboardOrigin}/somewhere-else`,
});
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const callback = new URL(link.searchParams.get("callbackURL") ?? "");
expect(callback.origin).toBe(dashboardOrigin);
expect(callback.pathname).toBe("/");
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
expect(link.href).not.toContain("somewhere-else");
});
it("keeps public signup disabled now that verification exists", async () => {
const install = await installation("signup");
const response = await post(install.origin, "/api/auth/sign-up/email", {
email: "[email protected]",
name: "Intruder",
password: initialPassword,
});
// Email verification is not a licence to self-register. Future accounts arrive through
// deliberate administration, which this pass does not build.
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" });
expect(await emailVerifiedFlag(install.url, "[email protected]")).toBeUndefined();
});
});
describe("managed devices are untouched by human email identity", () => {
it("leaves a paired device intact across a human password reset", async () => {
const install = await installation("devices");
const email = "[email protected]";
await verifiedAccountFor(install, email);
// A device is a row with a credential digest and no email, no password, and no verification
// state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove
// something this test is not about.
const client = new Client({ connectionString: install.url });
await client.connect();
try {
await safeQuery(
client,
'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)',
["Sanctuary display", "a".repeat(64)],
);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>(
client,
'select "credential_hash", "revoked_at" from "ekklesia_core_device"',
);
expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]);
// The device table has no email or verification concept for a human flow to reach.
const columns = await safeQuery<{ column_name: string }>(
client,
"select column_name from information_schema.columns where table_name = 'ekklesia_core_device'",
);
const names = columns.map((column) => column.column_name).join(" ");
expect(names).not.toContain("email");
expect(names).not.toContain("verified");
expect(names).not.toContain("password");
} finally {
await client.end().catch(() => undefined);
}
});
it("sends a device no mail, because a device has no address to send to", async () => {
const install = await installation("devices-mail");
await post(install.origin, "/api/auth/request-password-reset", {
email: "[email protected]",
});
// Nothing about device identity participates in either flow.
expect((install.mail as MailHarness).delivered).toHaveLength(0);
expect(install.notificationFailures).toEqual([]);
});
});
@@ -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" }),
);
});
});
+848
View File
@@ -0,0 +1,848 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
createAuthorizationDrizzleDatabase,
createInitialPlatformOwner,
InitialPlatformOwnerError,
} from "@ekklesia/core/authorization";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* The initial platform-owner bootstrap, against a real PostgreSQL and the real HTTP transport
* (ADR 0035).
*
* The point of this suite is that it does **not** stop at asserting rows. A fresh installation is
* bootstrapped and then the resulting account is signed in through the exact browser-facing login
* transport Phase 7B Pass 1 established, its principal is read back, and it is used to reach a
* platform-owner-protected resource. Anything less would prove the command wrote a user, not that
* an operator can actually get into their dashboard — which is the only thing this pass is for.
*
* Every scenario runs with `publicSignupEnabled: false`, the production posture, so nothing here
* can accidentally depend on the signup route the real deployment does not have.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
const bootstrapCliPath = path.join(
repositoryRoot,
"core",
"dist",
"cli",
"create-initial-owner.js",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const ownerPassword = "correct horse battery staple";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function baseDatabaseUrl(): string {
const url = process.env[integrationDatabaseUrlEnvironment];
if (typeof url !== "string" || url.trim() === "") {
throw new IntegrationHarnessError("The integration database URL is not configured.");
}
return url;
}
function databaseUrl(name: string): string {
const url = new URL(baseDatabaseUrl());
url.pathname = `/${name}`;
return url.toString();
}
function makeDatabaseName(label: string): string {
databaseCounter += 1;
return `ekk_owner_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
}
async function openAdministrativeClient(): Promise<Client> {
const client = new Client({ connectionString: baseDatabaseUrl() });
await client.connect();
return client;
}
async function safeQuery<T extends QueryResultRow>(
client: Client,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
try {
const result = await client.query<T>(text, [...values]);
return result.rows;
} catch {
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function createDisposableDatabase(label: string): Promise<string> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient();
try {
await safeQuery(admin, `create database "${name}"`);
} finally {
await admin.end().catch(() => undefined);
}
trackedDatabases.add(name);
return databaseUrl(name);
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-owner-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModules,
platformVersion: "0.1.0",
},
});
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModules, { force: true, recursive: true });
}
}
/** A fresh, migrated installation with no identity and no owner — exactly what an operator has
* after `docker compose up` and `pnpm migrations:apply`. */
async function freshInstallation(label: string): Promise<string> {
const url = await createDisposableDatabase(label);
await applyCoreMigrations(url);
return url;
}
interface BootstrapHandles {
readonly run: (
email: string,
password?: string,
name?: string,
) => ReturnType<typeof createInitialPlatformOwner>;
readonly close: () => Promise<void>;
}
/** Drives the same operation the CLI drives, with the same production composition. */
function bootstrapAgainst(url: string): BootstrapHandles {
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
return {
close: async () => {
await Promise.all([
authorization.close().catch(() => undefined),
identity.close().catch(() => undefined),
]);
},
run: (email, password = ownerPassword, name = "Platform Owner") =>
createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
identityDb: identity.db,
},
{ email, name, password },
),
};
}
/**
* The production posture: public signup disabled, exactly as a real deployment must run — and, just
* as deliberately, **no outbound email configured at all**. A fresh installation has no relay yet,
* which is the whole reason the initial owner is created already verified (ADR 0038).
*/
function ownerApiConfig(url: string): ReturnType<typeof createApiRuntimeConfig> {
return createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
publicSignupEnabled: false,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "development" },
);
}
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
}
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw new IntegrationHarnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
function firstCookiePair(setCookieHeader: string): string {
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
}
async function signIn(
origin: string,
email: string,
password: string,
): Promise<{ cookiePair?: string; response: Response }> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const setCookie = response.headers.get("set-cookie");
return setCookie === null ? { response } : { cookiePair: firstCookiePair(setCookie), response };
}
const execFileAsync = promisify(execFile);
interface CliOutcome {
readonly code: number;
readonly stdout: string;
readonly stderr: string;
}
/** Runs the real compiled CLI as a real child process, with the password on stdin and never in
* `argv`. */
async function runBootstrapCli(
databaseUrl: string | undefined,
args: readonly string[],
password: string | undefined,
): Promise<CliOutcome> {
const environment: Record<string, string> = {
...(process.env as Record<string, string>),
NODE_ENV: "development",
};
if (databaseUrl === undefined) {
delete environment.DATABASE_URL;
} else {
environment.DATABASE_URL = databaseUrl;
}
const child = execFileAsync(process.execPath, [bootstrapCliPath, ...args], {
env: environment,
});
child.child.stdin?.end(password ?? "");
try {
const { stdout, stderr } = await child;
return { code: 0, stderr, stdout };
} catch (error) {
const failure = error as { code?: number; stdout?: string; stderr?: string };
return {
code: typeof failure.code === "number" ? failure.code : 1,
stderr: failure.stderr ?? "",
stdout: failure.stdout ?? "",
};
}
}
async function countRows(url: string, table: string): Promise<number> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ count: string }>(
client,
`select count(*)::text from "${table}"`,
);
return Number(rows[0]?.count ?? "0");
} finally {
await client.end().catch(() => undefined);
}
}
/** Better Auth's own `emailVerified` column — the same value its `/verify-email` endpoint writes. */
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ email_verified: boolean }>(
client,
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
[email.trim().toLowerCase()],
);
return rows[0]?.email_verified;
} finally {
await client.end().catch(() => undefined);
}
}
afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined);
if (!admin) {
return;
}
try {
for (const name of trackedDatabases) {
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
() => undefined,
);
}
} finally {
await admin.end().catch(() => undefined);
}
});
describe.sequential("initial platform-owner bootstrap", () => {
it("takes a fresh installation all the way to an authenticated owner in the browser", async () => {
const url = await freshInstallation("browserproof");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
let created: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
try {
created = await bootstrap.run(email);
} finally {
await bootstrap.close();
}
expect(created.identity).toBe("created");
expect(created.email).toBe(email.toLowerCase());
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
// 1. The exact browser-facing login transport, on a deployment where signup does not exist.
const session = await signIn(origin, email, ownerPassword);
expect(session.response.status).toBe(200);
expect(session.cookiePair).toBeDefined();
const cookie = session.cookiePair as string;
// 2. The principal the browser bootstrap reads.
const principalResponse = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
expect(principalResponse.status).toBe(200);
const principal = (await principalResponse.json()) as {
kind: string;
userId?: string;
};
expect(principal.kind).toBe("authenticated");
expect(principal.userId).toBe(created.userId);
// 3. A platform-owner-protected resource actually opens for them. This is the assertion that
// separates "a row was written" from "the operator can run their platform".
const grants = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`, {
headers: { cookie },
});
expect(grants.status).toBe(200);
// 4. And the same resource is still closed to everyone else.
const anonymous = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`);
expect(anonymous.status).toBe(403);
} finally {
await api.shutdown();
}
});
it("leaves public signup disabled — the owner exists without a signup route existing", async () => {
const url = await freshInstallation("nosignup");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
const attempted = await fetch(`${origin}/api/auth/sign-up/email`, {
body: JSON.stringify({
email: `intruder-${randomBytes(4).toString("hex")}@example.test`,
name: "Intruder",
password: ownerPassword,
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
// The bootstrap command created an account; it did not open a door for anyone else to.
expect(attempted.status).toBe(400);
expect(await attempted.text()).toContain("EMAIL_PASSWORD_SIGN_UP_DISABLED");
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
// And the owner can still sign in, so disabling signup did not disable the account.
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
describe("the guard", () => {
it("refuses a second bootstrap, changes no password, and creates no second owner", async () => {
const url = await freshInstallation("guard");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
// Same address, different password: must not become a password reset.
await expect(bootstrap.run(email, "an entirely different password")).rejects.toMatchObject({
code: "INITIAL_OWNER_ALREADY_COMPLETED",
});
// A different address must not become a second owner.
await expect(
bootstrap.run(`second-${randomBytes(4).toString("hex")}@example.test`),
).rejects.toMatchObject({ code: "INITIAL_OWNER_ALREADY_COMPLETED" });
} finally {
await bootstrap.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
// The refused second attempt with a new address created no stray identity: the sentinel is
// checked before anything is created, not after.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
expect(
(await signIn(origin, email, "an entirely different password")).response.status,
).not.toBe(200);
} finally {
await api.shutdown();
}
});
});
describe("validation reuses the identity system's own policy", () => {
it("rejects an invalid email, and a password outside the 8–128 bound", async () => {
const url = await freshInstallation("validation");
const bootstrap = bootstrapAgainst(url);
try {
await expect(bootstrap.run("not-an-email-address")).rejects.toMatchObject({
code: "INITIAL_OWNER_EMAIL_INVALID",
});
await expect(
bootstrap.run(`short-${randomBytes(4).toString("hex")}@example.test`, "1234567"),
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_SHORT" });
await expect(
bootstrap.run(`long-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(129)),
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_LONG" });
// Exactly the boundary the browser accepts, so the two can never diverge.
await expect(
bootstrap.run(`edge-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(128)),
).resolves.toMatchObject({ identity: "created" });
} finally {
await bootstrap.close();
}
// Every rejection happened before anything was written, and the one success is the only row.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
});
it("never puts the password into an error", async () => {
const url = await freshInstallation("nosecrets");
const secret = "sup3rSecretBootstrapPassword!";
const bootstrap = bootstrapAgainst(url);
try {
let caught: unknown;
try {
await bootstrap.run("not-an-email-address", secret);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(InitialPlatformOwnerError);
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain(secret);
expect(rendered).not.toContain("postgres");
expect(rendered).not.toContain("select");
} finally {
await bootstrap.close();
}
});
});
describe("an identity that already exists", () => {
it("adopts a usable account and leaves its password untouched", async () => {
const url = await freshInstallation("adopt");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
// Exactly the partial failure this guards: the identity committed, the grant never ran.
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createBootstrapIdentityCreator(identity.db)({
email,
name: "Half Finished",
password: ownerPassword,
});
} finally {
await identity.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
const bootstrap = bootstrapAgainst(url);
try {
const result = await bootstrap.run(email, "a different password than before");
expect(result.identity).toBe("adopted");
} finally {
await bootstrap.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
// Adoption granted ownership. It did not take the account over: the original password still
// works and the one supplied to the rerun does not.
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
expect(
(await signIn(origin, email, "a different password than before")).response.status,
).not.toBe(200);
} finally {
await api.shutdown();
}
});
it("refuses a credential-less row rather than consuming bootstrap on an unusable owner", async () => {
const url = await freshInstallation("unusable");
const client = new Client({ connectionString: url });
await client.connect();
try {
await safeQuery(
client,
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
values ($1, $2, $3, false, now(), now())`,
["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Shell Row", "[email protected]"],
);
} finally {
await client.end().catch(() => undefined);
}
const bootstrap = bootstrapAgainst(url);
try {
await expect(bootstrap.run("[email protected]")).rejects.toMatchObject({
code: "INITIAL_OWNER_IDENTITY_UNUSABLE",
});
} finally {
await bootstrap.close();
}
// The decisive part: bootstrap is still available. Granting ownership to a row nobody can
// authenticate as would have consumed the single use and left the installation unusable with
// no second chance.
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
});
});
describe("partial failure", () => {
it("reports a failed grant honestly and stays recoverable by rerunning", async () => {
const url = await freshInstallation("partial");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
try {
await expect(
createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
grantOwnership: () => {
throw new Error("the grant failed after the identity committed");
},
identityDb: identity.db,
},
{ email, name: "Owner", password: ownerPassword },
),
).rejects.toMatchObject({ code: "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" });
} finally {
await Promise.all([authorization.close(), identity.close()]);
}
// The identity committed; the grant did not; the single-use sentinel was never consumed.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
// Rerunning the identical command finishes the job, which is the whole recovery story.
const retry = bootstrapAgainst(url);
try {
await expect(retry.run(email)).resolves.toMatchObject({ identity: "adopted" });
} finally {
await retry.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
});
});
describe("concurrency", () => {
it("gives exactly one owner when several bootstraps race", async () => {
const url = await freshInstallation("race");
const attempts = 5;
const handles = Array.from({ length: attempts }, () => bootstrapAgainst(url));
try {
const outcomes = await Promise.allSettled(
handles.map((handle, index) =>
handle.run(`racer-${index}-${randomBytes(3).toString("hex")}@example.test`),
),
);
const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled");
expect(fulfilled).toHaveLength(1);
for (const outcome of outcomes.filter((candidate) => candidate.status === "rejected")) {
expect((outcome as PromiseRejectedResult).reason).toMatchObject({
code: "INITIAL_OWNER_ALREADY_COMPLETED",
});
}
} finally {
await Promise.all(handles.map((handle) => handle.close()));
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(1);
// The losers created nothing at all. Serializing whole invocations — not just the grant — is
// what keeps a race from leaving signable-in accounts that own nothing behind.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
});
});
describe("the command itself", () => {
it("succeeds, prints something useful, and leaks nothing", async () => {
const url = await freshInstallation("cli");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const result = await runBootstrapCli(url, ["--email", email], ownerPassword);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Initial platform owner created successfully.");
expect(result.stdout).toContain(email.toLowerCase());
const everything = `${result.stdout} ${result.stderr}`;
expect(everything).not.toContain(ownerPassword);
expect(everything).not.toContain("postgresql://");
expect(everything).not.toContain("password");
const api = await startApiWithoutSignup(url);
try {
// The command's real product: an operator who can now sign in.
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
it("refuses a password supplied on the command line", async () => {
const url = await freshInstallation("argv");
const result = await runBootstrapCli(
url,
["--email", "[email protected]", "--password", "hunter2hunter2"],
undefined,
);
expect(result.code).toBe(1);
expect(result.stderr).toContain("--password is not supported");
expect(await countRows(url, "ekklesia_core_user")).toBe(0);
});
it("fails clearly without a database, and refuses to migrate one itself", async () => {
const missing = await runBootstrapCli(
undefined,
["--email", "[email protected]"],
"whatever",
);
expect(missing.code).toBe(1);
expect(missing.stderr).toContain("DATABASE_URL is required.");
// A database that exists but was never migrated is a deployment step that has not happened,
// and this command says so rather than quietly running migrations for a production operator.
const unmigrated = await createDisposableDatabase("unmigrated");
const result = await runBootstrapCli(
unmigrated,
["--email", "[email protected]"],
ownerPassword,
);
expect(result.code).toBe(1);
expect(result.stderr).toContain("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
const client = new Client({ connectionString: unmigrated });
await client.connect();
try {
const tables = await safeQuery<{ count: string }>(
client,
`select count(*)::text from information_schema.tables where table_schema = 'public'`,
);
expect(Number(tables[0]?.count ?? "0")).toBe(0);
} finally {
await client.end().catch(() => undefined);
}
});
it("refuses a second run at the command level too", async () => {
const url = await freshInstallation("clirerun");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
expect((await runBootstrapCli(url, ["--email", email], ownerPassword)).code).toBe(0);
const second = await runBootstrapCli(url, ["--email", email], "a different password");
expect(second.code).toBe(1);
expect(second.stderr).toContain("INITIAL_OWNER_ALREADY_COMPLETED");
expect(second.stderr).not.toContain("a different password");
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
});
});
});
describe.sequential("initial platform-owner bootstrap and email verification", () => {
/**
* The trusted-bootstrap verification exception (ADR 0038).
*
* A verified address became a precondition for holding a session in Phase 7C Pass 2. The initial
* owner is created on a freshly provisioned server, by an operator holding the database
* credentials, at a moment when SMTP may not exist — so requiring an email round trip here would
* produce a working installation nobody can ever sign in to. These scenarios exist to prove that
* the exception is real, that it is narrow, and that mail remains genuinely absent throughout.
*/
it("creates the owner already verified, proven in the database and in the browser", async () => {
const url = await freshInstallation("verifiedowner");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
// Database state: the exact column Better Auth's own verification endpoint writes, so the row
// is indistinguishable from one that verified normally.
expect(await emailVerifiedFlag(url, email)).toBe(true);
// ...and the browser-facing consequence, which is the only thing that actually matters.
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
const session = await signIn(origin, email, ownerPassword);
expect(session.response.status).toBe(200);
expect(session.cookiePair).toBeDefined();
} finally {
await api.shutdown();
}
});
it("needs no outbound email configured, at bootstrap or at sign-in", async () => {
const url = await freshInstallation("nomailowner");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
// No SMTP host, no relay, no network: `createBootstrapIdentityCreator` constructs its own
// Better Auth instance with no `emailVerification` configuration at all.
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
const api = await startApiWithoutSignup(url);
try {
// The API this suite starts configures no mail either, so nothing in this scenario could have
// sent or received a message.
expect(ownerApiConfig(url).mail).toBeUndefined();
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
it("marks an adopted identity verified too, so bootstrap never yields an owner who cannot sign in", async () => {
const url = await freshInstallation("adoptverified");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
// An identity that exists before bootstrap runs — created here through the same trusted creator,
// which always writes `emailVerified: false`.
const first = bootstrapAgainst(url);
try {
await first.run(email);
} finally {
await first.close();
}
const client = new Client({ connectionString: url });
await client.connect();
try {
await safeQuery(client, 'update "ekklesia_core_user" set "email_verified" = false');
await safeQuery(client, 'delete from "ekklesia_core_platform_owner_bootstrap"');
await safeQuery(client, 'delete from "ekklesia_core_platform_owner"');
} finally {
await client.end().catch(() => undefined);
}
expect(await emailVerifiedFlag(url, email)).toBe(false);
const second = bootstrapAgainst(url);
let adopted: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
try {
adopted = await second.run(email);
} finally {
await second.close();
}
expect(adopted.identity).toBe("adopted");
expect(await emailVerifiedFlag(url, email)).toBe(true);
});
it("verifies only the owner, leaving every other account subject to the normal policy", async () => {
const url = await freshInstallation("narrowexception");
const ownerEmail = `owner-${randomBytes(4).toString("hex")}@example.test`;
const otherEmail = `member-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(ownerEmail);
// A second identity created through the identical trusted creator, but never bootstrapped.
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createBootstrapIdentityCreator(identity.db)({
email: otherEmail,
name: "Ordinary Member",
password: ownerPassword,
});
} finally {
await identity.close().catch(() => undefined);
}
} finally {
await bootstrap.close();
}
expect(await emailVerifiedFlag(url, ownerEmail)).toBe(true);
expect(await emailVerifiedFlag(url, otherEmail)).toBe(false);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
expect((await signIn(origin, ownerEmail, ownerPassword)).response.status).toBe(200);
// The exception did not widen: an ordinary account still has to verify, and on this
// mail-less installation it simply cannot sign in yet.
const refused = await signIn(origin, otherEmail, ownerPassword);
expect(refused.response.status).toBe(403);
expect(await refused.response.json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
expect(refused.cookiePair).toBeUndefined();
} finally {
await api.shutdown();
}
});
});
+114 -17
View File
@@ -8,11 +8,16 @@ const betterAuthMock = vi.hoisted(() => vi.fn());
const drizzleAdapterMock = vi.hoisted(() => vi.fn()); const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn()); const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn()); const processOnSpy = vi.hoisted(() => vi.fn());
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor })); vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock })); vi.mock("@hono/node-server", () => ({ serve: serveMock }));
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
});
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still // Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
// gets the real Hono application; only the one test that deliberately forces an app-construction // gets the real Hono application; only the one test that deliberately forces an app-construction
// failure overrides this with `mockImplementationOnce`. // failure overrides this with `mockImplementationOnce`.
@@ -30,6 +35,7 @@ import {
import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { DisplayHostAssetError } from "../src/display-host-assets.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() { function validConfig() {
@@ -48,6 +54,21 @@ function validConfig() {
); );
} }
/**
* The one query behavior every fake pool in this file needs: startup reads the stored module
* enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module
* entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning
* an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database
* — not a way to skip the read. The tests that care about a *specific* configuration drive the real
* resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the
* real store (`core/tests/module-lifecycle-store.integration.ts`).
*/
function emptyQuery() {
return vi
.fn()
.mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] });
}
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
async function createTemporaryModulesDirectory(): Promise<string> { async function createTemporaryModulesDirectory(): Promise<string> {
@@ -67,7 +88,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 3, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -134,6 +155,8 @@ interface WorkingFixtureModuleOptions {
* can be exercised deterministically without any sleep. */ * can be exercised deterministically without any sleep. */
readonly stopNeverSettles?: boolean; readonly stopNeverSettles?: boolean;
readonly dependencies?: Record<string, unknown>; readonly dependencies?: Record<string, unknown>;
/** Declares one protected display artifact without importing it. */
readonly withDisplay?: boolean;
} }
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not /** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
@@ -147,12 +170,43 @@ async function createWorkingFixtureModule(
options: WorkingFixtureModuleOptions = {}, options: WorkingFixtureModuleOptions = {},
): Promise<void> { ): Promise<void> {
const permissionId = `${id}.ping.read`; const permissionId = `${id}.ping.read`;
const displayPermission = `${id}.display.view`;
const permissions = [
...(options.withRoute
? [{ baseline: "public" as const, description: "Ping.", id: permissionId }]
: []),
...(options.withDisplay
? [
{
baseline: "restricted" as const,
description: "View the display.",
id: displayPermission,
},
]
: []),
];
await createFixtureModule(modulesDirectory, id, { await createFixtureModule(modulesDirectory, id, {
permissions: options.withRoute entrypoints: {
? [{ baseline: "public", description: "Ping.", id: permissionId }] server: "./dist/server/index.js",
: [], ...(options.withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}
: {}),
},
permissions,
...(options.dependencies ? { dependencies: options.dependencies } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}),
}); });
if (options.withDisplay) {
await mkdir(path.join(modulesDirectory, id, "dist/display"), { recursive: true });
await writeFile(
path.join(modulesDirectory, id, "dist/display/index.js"),
'throw new Error("display artifact must never execute during server startup");\n',
);
}
const routeSource = options.withRoute const routeSource = options.withRoute
? `http.route({ ? `http.route({
method: "GET", method: "GET",
@@ -200,12 +254,13 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
id, id,
manifestVersion: 3, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions, permissions,
version: "0.1.0", version: "0.1.0",
}); });
return { return {
contributions: [],
directory: `/modules/${id}`, directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest, manifest,
@@ -359,7 +414,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => { it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe(); const { server } = mockSuccessfulServe();
@@ -375,7 +430,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => { it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe(); mockSuccessfulServe();
@@ -395,7 +450,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => { it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe(); const { server } = mockSuccessfulServe();
@@ -411,7 +466,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("closes an already-created database pool when identity construction fails during startup", async () => { it("closes an already-created database pool when identity construction fails during startup", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockImplementation(() => { betterAuthMock.mockImplementation(() => {
throw new Error("postgresql://admin:[email protected]/x"); throw new Error("postgresql://admin:[email protected]/x");
@@ -429,7 +484,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => { it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe(); mockSuccessfulServe();
@@ -467,7 +522,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => { it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe(); mockSuccessfulServe();
@@ -520,7 +575,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
it("closes an already-created database pool when the server fails to bind its listen address", async () => { it("closes an already-created database pool when the server fails to bind its listen address", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockFailingServe(new Error("EADDRINUSE")); mockFailingServe(new Error("EADDRINUSE"));
@@ -584,7 +639,7 @@ describe("Ekklesia API Node server timeouts", () => {
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => { it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe(); mockSuccessfulServe();
@@ -621,7 +676,7 @@ describe("Ekklesia API Node server timeouts", () => {
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => { it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe(); mockSuccessfulServe();
@@ -673,7 +728,7 @@ describe("Ekklesia API Node server timeouts", () => {
async function startAndCaptureClientErrorHandler() { async function startAndCaptureClientErrorHandler() {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { clientErrorHandler } = mockSuccessfulServe(); const { clientErrorHandler } = mockSuccessfulServe();
@@ -891,7 +946,7 @@ describe("Ekklesia API module permission composition at startup", () => {
const modulesDirectory = await createTemporaryModulesDirectory(); const modulesDirectory = await createTemporaryModulesDirectory();
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe(); mockSuccessfulServe();
@@ -927,18 +982,59 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
betterAuthMock.mockReset(); betterAuthMock.mockReset();
drizzleAdapterMock.mockClear(); drizzleAdapterMock.mockClear();
serveMock.mockReset(); serveMock.mockReset();
loadDisplayHostAssetsMock.mockReset();
vi.mocked(createApiApp).mockClear(); vi.mocked(createApiApp).mockClear();
}); });
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } { function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() }; return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
return { poolEnd }; return { poolEnd };
} }
it("fails on missing display-host assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "announcements", recorder, {
withDisplay: true,
});
const { poolEnd } = mockPoolAndAuth();
loadDisplayHostAssetsMock.mockRejectedValueOnce(new DisplayHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
message: "The display host browser assets are unavailable.",
name: "DisplayHostAssetError",
});
expect((failure as Error).stack).toBe(
"DisplayHostAssetError: The display host browser assets are unavailable.",
);
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read display-host assets when the eligible set declares no display", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
await running.shutdown();
});
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => { it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory(); const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder(); const recorder = createEventRecorder();
@@ -1131,6 +1227,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
recorder.events.push("pool:end"); recorder.events.push("pool:end");
}), }),
on: vi.fn(), on: vi.fn(),
query: emptyQuery(),
}; };
}); });
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
@@ -0,0 +1,800 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleConfigurationDeclarationInput,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type ModuleConfigurationRecord,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
resolveModuleEligibility,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import {
createFakeModuleSecretSource,
createInMemoryModuleConfigurationStore,
} from "./support/in-memory-module-configuration.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
/**
* The configuration shape the real Announcements product would need as an Ekklesia module: a slide
* duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied
* storage credential.
*
* Modelled from the reference repository, never imported from it. Nothing Announcements-specific
* exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive
* the whole path — manifest, validation, storage, API, snapshot — without the module reaching any
* platform infrastructure.
*/
const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [
{
key: "slideSeconds",
type: "integer",
default: 7,
minimum: 1,
maximum: 600,
description: "How long each slide is shown.",
},
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
{ key: "storageCredential", type: "secret", required: true },
];
interface ModuleFixture {
readonly id: string;
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" },
id: fixture.id,
manifestVersion: 7,
name: `${fixture.id} module`,
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
),
]);
}
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly grants?: readonly string[];
readonly records?: readonly ModuleConfigurationRecord[];
readonly secrets?: Readonly<Record<string, Readonly<Record<string, string>>>>;
readonly store?: ModuleConfigurationStore;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly store: ModuleConfigurationStore;
}
/**
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
* composition: read the stored settings, resolve eligibility and configuration in one pass, then
* build the UI registry and every option from that same resolution. Doing it the same way here is
* what makes these assertions evidence about production composition rather than a bespoke wiring.
*/
async function install(options: InstallOptions = {}): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures ?? [
{ configuration: announcementsConfiguration, id: "announcements" },
]) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []);
const secrets = createFakeModuleSecretSource(options.secrets ?? {});
const lifecycleStore = createInMemoryModuleLifecycleStore();
const bootSettings = await store.readSettings();
const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), {
secrets,
settings: bootSettings,
});
const held = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const authorizationStore = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: {
evaluator,
registry: composePlatformPermissionRegistry(modules),
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
moduleConfiguration: {
bootSettings,
discoveredModules: modules,
secrets,
snapshots: eligibility.configurations,
store,
},
moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: {
snapshot: {
discoveredModules: modules,
eligibility: eligibility.entries,
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
},
store: lifecycleStore,
},
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, store };
}
function owner(): InstallOptions {
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
}
interface ConfigurationBody {
readonly moduleId: string;
readonly version: string;
readonly keys: readonly {
readonly key: string;
readonly type: string;
readonly required: boolean;
readonly description?: string;
readonly default?: string | number | boolean;
readonly values?: readonly string[];
readonly value?: string;
readonly configured: boolean;
}[];
readonly unknownKeys: readonly string[];
readonly restartRequired: boolean;
}
async function readConfiguration(
app: Hono,
moduleId = "announcements",
): Promise<ConfigurationBody> {
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(200);
return (await response.json()) as ConfigurationBody;
}
function putConfiguration(
app: Hono,
values: unknown,
moduleId = "announcements",
origin = trustedOrigin,
) {
return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
body: JSON.stringify({ values }),
headers: { "content-type": "application/json", origin },
method: "PUT",
});
}
describe("GET /api/v1/module-configuration/:moduleId", () => {
it("returns the module's declared schema with defaults and stored values", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
secrets: { announcements: { storageCredential: "s3cret" } },
});
const body = await readConfiguration(app);
expect(body.moduleId).toBe("announcements");
expect(body.keys.map((entry) => entry.key)).toEqual([
"slideSeconds",
"pollIntervalSeconds",
"bucket",
"fit",
"storageCredential",
]);
expect(body.keys[0]).toEqual({
configured: false,
default: 7,
description: "How long each slide is shown.",
key: "slideSeconds",
required: false,
type: "integer",
});
expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({
configured: true,
required: true,
value: "slides",
});
expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]);
});
it("never returns a secret value, only whether the deployment supplies one", async () => {
const configured = await install({
...owner(),
secrets: { announcements: { storageCredential: "s3cret" } },
});
const body = await readConfiguration(configured.app);
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
expect(secretEntry).toEqual({
configured: true,
key: "storageCredential",
required: true,
type: "secret",
});
// Not redacted, not null, not a placeholder — the field is absent entirely, and the secret
// string appears nowhere in the payload.
expect(secretEntry).not.toHaveProperty("value");
expect(secretEntry).not.toHaveProperty("default");
expect(JSON.stringify(body)).not.toContain("s3cret");
});
it("reports an unsupplied secret as not configured, still without a value field", async () => {
const { app } = await install(owner());
const body = await readConfiguration(app);
expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({
configured: false,
key: "storageCredential",
required: true,
type: "secret",
});
});
it("lists stored keys the current manifest no longer declares, without failing", async () => {
const { app } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "removedInThisVersion", moduleId: "announcements", value: "kept" },
],
});
const body = await readConfiguration(app);
expect(body.unknownKeys).toEqual(["removedInThisVersion"]);
expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion");
});
it("returns a module declaring no configuration as an empty, valid document", async () => {
const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] });
const body = await readConfiguration(app, "calendar");
expect(body.keys).toEqual([]);
expect(body.unknownKeys).toEqual([]);
expect(body.restartRequired).toBe(false);
});
it("refuses a module this process did not discover", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
});
it("is not cached and varies on origin and cookie", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(response.headers.get("Vary")).toContain("Cookie");
});
it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => {
const failing: ModuleConfigurationStore = {
readSettings: async () => {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED");
},
replace: async () => {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED");
},
};
let bootRead = false;
const { app } = await install({
...owner(),
store: {
readSettings: async () => {
if (!bootRead) {
bootRead = true;
return { records: [] };
}
return failing.readSettings();
},
replace: (input) => failing.replace(input),
},
});
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" });
});
});
describe("PUT /api/v1/module-configuration/:moduleId", () => {
it("stores values and reports that a restart is required", async () => {
const { app, store } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 });
expect(response.status).toBe(200);
const body = (await response.json()) as ConfigurationBody;
expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({
configured: true,
value: "12",
});
// The running process still holds the boot snapshot; claiming otherwise would be a lie.
expect(body.restartRequired).toBe(true);
expect((await store.readSettings()).records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
]);
});
it("reports no pending restart when the submitted document matches what booted", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await putConfiguration(app, { bucket: "slides" });
expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false);
});
it("replaces the whole document, so an omitted key is cleared", async () => {
const { app, store } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
],
});
await putConfiguration(app, { bucket: "slides" });
expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]);
});
it("never touches another module's stored values", async () => {
const { app, store } = await install({
...owner(),
fixtures: [
{ configuration: announcementsConfiguration, id: "announcements" },
{ configuration: [{ key: "bucket", type: "string" }], id: "calendar" },
],
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "bucket", moduleId: "calendar", value: "events" },
],
});
await putConfiguration(app, { bucket: "renamed" });
expect((await store.readSettings()).records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "renamed" },
{ key: "bucket", moduleId: "calendar", value: "events" },
]);
});
it("refuses to accept secret material over HTTP", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, {
bucket: "slides",
storageCredential: "s3cret",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" });
// Nothing was written at all — not the secret, and not the valid key beside it.
expect((await store.readSettings()).records).toEqual([]);
});
it("rejects a key the module does not declare", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" });
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" });
expect((await store.readSettings()).records).toEqual([]);
});
it.each([
["a string for an integer", { bucket: "slides", slideSeconds: "12" }],
["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }],
["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }],
["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }],
["a value outside a declared enum", { bucket: "slides", fit: "stretch" }],
["a string shorter than its declared minLength", { bucket: "" }],
["a nested object", { bucket: { name: "slides" } }],
["an array", { bucket: ["slides"] }],
["null", { bucket: null }],
])("rejects %s without storing anything", async (_label, values) => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, values);
expect(response.status).toBe(400);
expect((await store.readSettings()).records).toEqual([]);
});
it.each([
["a body with an unexpected top-level key", { values: {}, force: true }],
["a body with no values object", {}],
["a bare array body", []],
["a bare string body", "bucket=slides"],
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
});
it.each([
"__proto__",
"constructor",
"prototype",
])("rejects the reserved property name %s as a submitted key", async (key) => {
const { app, store } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: `{"values":{${JSON.stringify(key)}:"polluted"}}`,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect((await store.readSettings()).records).toEqual([]);
// Nothing about the request reached `Object.prototype`.
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => {
const { app, store } = await install(owner());
// `bucket` is required and absent: an operator may save partial work.
const response = await putConfiguration(app, { slideSeconds: 12 });
expect(response.status).toBe(200);
expect((await store.readSettings()).records).toEqual([
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
]);
});
it("rejects an untrusted origin before authorization or the store runs", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(
app,
{ bucket: "slides" },
"announcements",
untrustedOrigin,
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect((await store.readSettings()).records).toEqual([]);
});
it("rejects a missing origin", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: JSON.stringify({ values: { bucket: "slides" } }),
headers: { "content-type": "application/json" },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
const { app } = await install(owner());
const oversized = JSON.stringify({
values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) },
});
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: oversized,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
});
describe("module configuration authority", () => {
it("refuses an anonymous caller on both verbs", async () => {
const { app } = await install();
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403);
});
it("refuses an ordinary authenticated user who holds no configuration permission", async () => {
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
});
it("admits a non-owner holding exactly core.modules.configuration.manage", async () => {
const { app } = await install({
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200);
});
it("does not let the lifecycle permission configure a module, or the reverse", async () => {
// The two authorities are deliberately separate: tuning how a module behaves and deciding
// whether it runs at all are different decisions (ADR 0029).
const lifecycleOnly = await install({
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect(
(await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
).toBe(403);
expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
const configurationOnly = await install({
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403);
expect(
(await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
).toBe(200);
});
});
describe("configuration state is visible through the lifecycle resource", () => {
it("reports a module blocked by missing required configuration, naming the key not the value", async () => {
const { app } = await install({
...owner(),
secrets: { announcements: { storageCredential: "s3cret" } },
});
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
const body = (await response.json()) as {
readonly modules: readonly {
readonly id: string;
readonly runtime: {
readonly state: string;
readonly reason?: string;
readonly configurationKey?: string;
};
}[];
};
expect(body.modules[0]?.runtime).toEqual({
configurationKey: "bucket",
reason: "configuration_missing",
state: "blocked",
});
});
it("reports a module blocked by a missing secret, without naming the secret's value", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
const raw = await response.text();
expect(raw).toContain("secret_missing");
expect(raw).toContain("storageCredential");
});
it("keeps a configuration-blocked module out of the mountable UI inventory", async () => {
const { app } = await install({
...owner(),
fixtures: [
{ configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" },
],
});
const inventory = (await (await app.request("/api/v1/module-ui")).json()) as {
readonly modules: readonly { readonly id: string }[];
};
// Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is
// not eligible, so it is not published.
expect(inventory.modules).toEqual([]);
});
});
describe("no secret or configuration value leaks through any surface", () => {
it("keeps secret material out of every response body this resource produces", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
secrets: { announcements: { storageCredential: "super-secret-value" } },
});
const listed = await (
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
})
).text();
const mutated = await (await putConfiguration(app, { bucket: "slides" })).text();
const lifecycle = await (
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
).text();
for (const payload of [listed, mutated, lifecycle]) {
expect(payload).not.toContain("super-secret-value");
}
});
it("does not serve a secret value even if a row for that key somehow exists in storage", async () => {
// Unreachable through the API, which refuses to write a secret key, and unreachable through the
// resolver, which never reads one. This plants the row directly to prove the *serialization*
// boundary holds independently: a secret's `value` is omitted by construction, not filtered by a
// branch someone could remove.
const { app } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" },
],
});
const body = await readConfiguration(app);
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
expect(secretEntry).not.toHaveProperty("value");
expect(secretEntry?.configured).toBe(false);
expect(JSON.stringify(body)).not.toContain("planted-in-the-database");
});
it("never leaks a filesystem path, package name, or manifest path", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const listed = await (
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
})
).text();
expect(listed).not.toContain("/tmp");
expect(listed).not.toContain("@test/module-");
expect(listed).not.toContain("ekklesia.module.json");
expect(listed).not.toContain("dist/");
});
});
describe("the configuration prefix is a sibling of the module route namespace", () => {
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
// `config` and `configuration` are ordinary lowercase static segments under
// `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on
// `/api/v1/modules/announcements/config`. A sibling prefix cannot collide.
expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false);
expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration");
const { app } = await install(owner());
const collision = await app.request("/api/v1/modules/announcements/config", {
body: JSON.stringify({ values: {} }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(collision.status).toBe(404);
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
});
});
+52 -3
View File
@@ -12,7 +12,7 @@ import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest"; import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { createApiDatabase } from "../src/database.js"; import { createApiDatabase } from "../src/database.js";
import { startApi, type RunningApi } from "../src/server.js"; import { type RunningApi, startApi } from "../src/server.js";
/** /**
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module * Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
@@ -220,6 +220,11 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0000_identity_core_tables", "core:0000_identity_core_tables",
"core:0001_authorization_core_tables", "core:0001_authorization_core_tables",
"core:0002_authorization_integrity", "core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -245,6 +250,11 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0000_identity_core_tables", "core:0000_identity_core_tables",
"core:0001_authorization_core_tables", "core:0001_authorization_core_tables",
"core:0002_authorization_integrity", "core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"db-facade:0000_initial", "db-facade:0000_initial",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
@@ -311,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
return { userId: body.user.id }; return { userId: body.user.id };
} }
/**
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
*
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
* module database access rather than identity, so it completes that step directly rather than
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
* `email-verification.integration.ts`.
*/
async function markEmailVerified(url: string, email: string): Promise<void> {
const client = new Client({ connectionString: url });
await client.connect();
try {
await client.query(
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
[email.trim().toLowerCase()],
);
} finally {
await client.end().catch(() => undefined);
}
}
async function signIn(origin: string, email: string, password: string): Promise<string> { async function signIn(origin: string, email: string, password: string): Promise<string> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, { const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
@@ -439,6 +470,10 @@ describe.sequential("live API — module database explicit migration ownership",
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe( expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
1, 1,
); );
expect(
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
).toBe(1);
expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1);
} finally { } finally {
await dropDatabase(database.name); await dropDatabase(database.name);
} }
@@ -495,11 +530,12 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" }); expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
const signedUp = await signUp(origin, "[email protected]", "correct horse battery"); const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
await markEmailVerified(database.url, "[email protected]");
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery"); ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
expect(signedUp.userId).toBeDefined(); expect(signedUp.userId).toBeDefined();
}); });
it("authorized read reflects real PostgreSQL state, exposes exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => { it("authorized read reflects real PostgreSQL state, exposes the exact scoped handler context, and the handler's database is reference-equal to start's", async () => {
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`); const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" }); expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
@@ -509,7 +545,20 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
const readHandlerEvent = [...events] const readHandlerEvent = [...events]
.reverse() .reverse()
.find((event) => event.type === "handler" && event.route === "GET /records/:id"); .find((event) => event.type === "handler" && event.route === "GET /records/:id");
expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]); // The complete handler-context surface, asserted exactly so a new field is a deliberate change
// here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime`
// (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono
// Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or
// credential — remains unreachable.
expect(readHandlerEvent?.contextKeys).toEqual([
"config",
"moduleId",
"params",
"principal",
"realtime",
"services",
"storage",
]);
expect(readHandlerEvent?.services).toBeDefined(); expect(readHandlerEvent?.services).toBeDefined();
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database); expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
}); });
@@ -0,0 +1,564 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
ModuleDisplayConfigurationError,
moduleDisplayDescriptorUrl,
moduleDisplayPageUrl,
} from "../src/module-display-routes.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const trustedOrigin = "https://dashboard.example.test";
const displayPermission = "announcements.display.view";
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
);
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly display?: boolean;
readonly id?: string;
readonly ui?: boolean;
readonly version?: string;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
const id = fixture.id ?? "announcements";
const version = fixture.version ?? "1.4.2";
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version,
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
...(fixture.display
? {
display: {
artifact: "./dist/display/index.js",
permission: `${id}.display.view`,
},
}
: {}),
},
id,
manifestVersion: 7,
name: `${id} module`,
permissions: fixture.display
? [
{
baseline: "restricted",
description: `View the ${id} display.`,
id: `${id}.display.view`,
},
]
: [],
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
);
if (fixture.ui) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
);
}
if (fixture.display) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/display/index.js"),
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
);
}
}
interface InstallOptions extends ModuleFixture {
readonly assets?: DisplayHostAssets;
readonly evaluator?: AuthorizationEvaluator;
readonly granted?: readonly string[];
readonly principal?: EkklesiaPrincipal;
/** The API builds publication from the eligible/running set. `false` models a module that was
* discovered but disabled or dependency-blocked before runtime startup. */
readonly published?: boolean;
readonly withAssets?: boolean;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
}
function fakeServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
function fakeAuthorizationStore(): AuthorizationStore {
return {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
}
async function install(options: InstallOptions): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
temporaryDirectories.push(modulesDirectory);
await createModule(modulesDirectory, options);
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const granted = new Set(options.granted ?? []);
const evaluator =
options.evaluator ??
createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
platformOwners: { isPlatformOwner: async () => false },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const publishedModules = options.published === false ? [] : modules;
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
const app = createApiApp({
authorization: {
evaluator,
registry: composePlatformPermissionRegistry(modules),
store: fakeAuthorizationStore(),
},
dashboard: createInMemoryDashboardStore(),
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
? { displayHostAssets: options.assets ?? displayHostAssets }
: {}),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleRoutes: Object.freeze([]),
moduleServices: fakeServices(),
moduleStorage: moduleStorageOptionFor(modules),
moduleUi,
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
describe("module display routes — authorization and descriptor", () => {
it("gives an unpaired viewer the inert shell but never the descriptor", async () => {
const { app } = await install({ display: true });
const [page, descriptor] = await Promise.all([
app.request(moduleDisplayPageUrl("announcements")),
app.request(moduleDisplayDescriptorUrl("announcements")),
]);
// The page is deliberately open: a television nobody has paired has no identity to present, and
// a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing —
// the same module-agnostic bytes every display gets, with no module id and no data.
expect(page.status).toBe(200);
expect(await page.text()).toBe(displayHostAssets.html);
// Authorization is entirely in the descriptor, and it has not moved.
expect(descriptor.status).toBe(403);
expect(descriptor.headers.get("cache-control")).toBe("no-store");
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("requires the declared display permission, not merely an authenticated principal", async () => {
const { app } = await install({ display: true, principal: signedIn() });
// Being signed in is not being permitted, and the descriptor is where that is decided.
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
});
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
version: "1.4.2",
});
const page = await app.request(moduleDisplayPageUrl("announcements"));
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(page.headers.get("cache-control")).toBe("no-store");
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
expect(await page.text()).toBe(displayHostAssets.html);
expect(await descriptor.json()).toEqual({
viewer: { kind: "authenticated", userId: viewerId },
module: {
contributions: [],
api: { baseUrl: moduleApiBaseUrl("announcements") },
display: {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
},
id: "announcements",
page: { url: moduleDisplayPageUrl("announcements") },
permissions: [displayPermission],
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
version: "1.4.2",
},
});
expect(descriptor.headers.get("cache-control")).toBe("no-store");
expect(descriptor.headers.get("content-type")).toContain("application/json");
expect(descriptor.headers.get("vary")).toContain("Origin");
expect(descriptor.headers.get("vary")).toContain("Cookie");
});
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
const authorize = vi
.fn()
.mockResolvedValueOnce({ allowed: true })
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
const evaluator: AuthorizationEvaluator = { authorize };
const { app } = await install({ display: true, evaluator, principal: signedIn() });
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
const body = await response.text();
expect(authorize).toHaveBeenCalledTimes(2);
expect(response.status).toBe(503);
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("vary")).toContain("Origin");
expect(response.headers.get("vary")).toContain("Cookie");
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
expect(body).not.toContain(secret);
}
});
it("keeps the outer page stable when the internal artifact version changes", () => {
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
);
});
});
describe("module display routes — role and lifecycle separation", () => {
it("keeps a display-only module out of the primary UI inventory", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
expect(inventory.status).toBe(200);
expect(await inventory.json()).toEqual({ modules: [] });
});
it("does not give a dashboard-only module a display route", async () => {
const { app } = await install({ principal: signedIn(), ui: true });
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
expect(inventory.status).toBe(200);
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
modules: [{ id: "announcements" }],
});
});
it("publishes both roles independently when a module declares both", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
ui: true,
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
expect(
(
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
modules: Array<{ display: { url: string } | null }>;
}
).modules,
).toEqual([
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
]);
});
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
published: false,
});
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
expect(
(
await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
)
).status,
).toBe(404);
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
});
it("registers only the exact stable page and descriptor paths", async () => {
const { app } = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
for (const route of [
"/display",
"/display/announcements/",
"/display/announcements/settings",
"/api/v1/module-displays",
"/api/v1/module-displays/announcements/",
"/api/v1/module-displays/announcements/settings",
]) {
expect((await app.request(route)).status, route).toBe(404);
}
});
it("fails composition when a display exists but its host assets were not supplied", async () => {
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
ModuleDisplayConfigurationError,
);
});
});
describe("module display host asset publication", () => {
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
const { app } = await install({ display: true });
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(displayHostAssets.script);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": displayHostAssets.scriptEtag },
});
expect(notModified.status).toBe(304);
expect(await notModified.text()).toBe("");
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
expect(notModified.headers.get("cache-control")).toBe("no-cache");
for (const header of [
`W/${displayHostAssets.scriptEtag}`,
"*",
`"stale", ${displayHostAssets.scriptEtag}`,
]) {
expect(
(
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": header },
})
).status,
header,
).toBe(304);
}
expect(
(
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
headers: { "if-none-match": '"stale"' },
})
).status,
).toBe(200);
});
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
const { app } = await install({ display: true });
for (const candidate of [
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
"/assets/ekklesia-display/index.html",
"/assets/ekklesia-display/secret.txt",
"/assets/ekklesia-display/../secret.txt",
"/api/v1/module-ui/announcements/1.4.2/management",
]) {
expect((await app.request(candidate)).status, candidate).toBe(404);
}
});
it("uses identical non-interpolated HTML for every eligible module", async () => {
const first = await install({
display: true,
granted: [displayPermission],
principal: signedIn(),
});
const second = await install({
display: true,
granted: ["signage.display.view"],
id: "signage",
principal: signedIn(),
});
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
expect(firstHtml).toBe(secondHtml);
expect(firstHtml).not.toContain("announcements");
expect(secondHtml).not.toContain("signage");
});
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
const source = createDisplayHostAssets(
"<html>display host</html>",
"export const host = 1;\n",
"export const worker = 1;\n",
);
const mutable = { ...source };
const { app } = await install({ assets: mutable, display: true });
mutable.html = "<html>mutated secret</html>";
mutable.script = "throw new Error('mutated secret');";
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
await expect(
install({
assets: { ...source, scriptEtag: '"stale-validator"' },
display: true,
}),
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
});
it("does not publish the generic host script when no eligible display uses it", async () => {
const { app } = await install({ ui: true, withAssets: false });
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
});
it("serves the offline service worker from the path whose directory is its scope", async () => {
const { app } = await install({ display: true });
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
// else. The Dashboard is never controlled by it (ADR 0043).
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
expect(response.status).toBe(200);
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("revalidates the service worker with its own validator, never the host script's", async () => {
const { app } = await install({ display: true });
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
});
expect(notModified.status).toBe(304);
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
headers: { "if-none-match": displayHostAssets.scriptEtag },
});
expect(wrongValidator.status).toBe(200);
});
it("does not publish the service worker when no eligible display uses it", async () => {
const { app } = await install({ ui: true, withAssets: false });
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
});
it("cannot be reached through a neighbouring or request-selected path", async () => {
const { app } = await install({ display: true });
for (const candidate of [
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
"/display/sw.js/",
"/sw.js",
"/assets/ekklesia-display/sw.js",
]) {
expect((await app.request(candidate)).status, candidate).toBe(404);
}
});
});
+410 -6
View File
@@ -17,13 +17,21 @@ import {
type PermissionRegistry, type PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { import {
canonicalizeVaryHeader, canonicalizeVaryHeader,
mergedVaryHeader, mergedVaryHeader,
ModuleRouteConfigurationError, ModuleRouteConfigurationError,
} from "../src/module-http-routes.js"; } from "../src/module-http-routes.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test"; const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test"; const untrustedOrigin = "https://evil.example.test";
@@ -50,14 +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,
@@ -105,7 +116,13 @@ function buildModuleApp(options: {
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: options.registry, store: fakeStore() }, authorization: { evaluator, registry: options.registry, store: fakeStore() },
moduleRoutes: options.routes, moduleRoutes: options.routes,
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services, moduleServices: services,
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
runtime, runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin], trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
}); });
@@ -331,13 +348,20 @@ describe("module route handler adaptation", () => {
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true"); expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
expect(capturedRequest).toBeInstanceOf(Request); expect(capturedRequest).toBeInstanceOf(Request);
expect(capturedRequest?.headers.get("x-custom")).toBe("abc"); expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store, // Exactly config, moduleId, params, principal, services, storage — and nothing else. `config`
// or configuration reaches a module handler. // is this module's own frozen configuration snapshot (ADR 0029) and `storage` its own scoped
// object storage (ADR 0030); a Hono Context, the authorization store or evaluator, raw
// PostgreSQL, the storage provider, an S3 endpoint or credential, platform configuration, and
// another module's configuration or storage all remain unreachable, which is what this
// exact-shape assertion exists to keep true.
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([ expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
"config",
"moduleId", "moduleId",
"params", "params",
"principal", "principal",
"realtime",
"services", "services",
"storage",
]); ]);
expect(capturedContext).toMatchObject({ expect(capturedContext).toMatchObject({
moduleId: "calendar", moduleId: "calendar",
@@ -350,6 +374,14 @@ describe("module route handler adaptation", () => {
expect(Object.isFrozen(capturedContext)).toBe(true); expect(Object.isFrozen(capturedContext)).toBe(true);
expect(Object.isFrozen(capturedContext?.params)).toBe(true); expect(Object.isFrozen(capturedContext?.params)).toBe(true);
expect(Object.isFrozen(capturedContext?.services)).toBe(true); expect(Object.isFrozen(capturedContext?.services)).toBe(true);
expect(Object.isFrozen(capturedContext?.config)).toBe(true);
// This module declared no configuration, so its snapshot is empty rather than absent — module
// code never has to branch on whether the platform supplied one.
expect(capturedContext?.config.keys()).toEqual([]);
// Likewise storage: this module declared no buckets, so it gets a frozen facade that refuses
// every one rather than `undefined`.
expect(Object.isFrozen(capturedContext?.storage)).toBe(true);
expect(capturedContext?.storage.buckets()).toEqual([]);
}); });
it("gives static and root routes a frozen, empty params object", async () => { it("gives static and root routes a frozen, empty params object", async () => {
@@ -1605,3 +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);
});
});
@@ -0,0 +1,578 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
CORE_MODULES_LIFECYCLE_MANAGE,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
resolveModuleEligibility,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly dependencies?: Record<string, unknown>;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 7,
name: `${fixture.id} module`,
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
),
writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
),
]);
}
const defaultFixtures: readonly ModuleFixture[] = [
{ id: "storage" },
{ id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } },
];
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly bootDisabled?: readonly string[];
readonly store?: ModuleLifecycleStore;
/** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator
* — never a bypass, so a delegated holder is exercised the same way a real one would be. */
readonly grants?: readonly string[];
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly store: ModuleLifecycleStore;
}
/**
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
* composition: resolve eligibility from the stored configuration, then build the UI publication
* registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it
* the same way here is what makes these assertions evidence about production composition rather than
* about a bespoke test wiring.
*/
async function install(options: InstallOptions = {}): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures ?? defaultFixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const store =
options.store ??
createInMemoryModuleLifecycleStore(
(options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })),
);
const bootConfiguration = await store.readConfiguration();
const eligibility = resolveModuleEligibility(modules, bootConfiguration);
const held = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const authorizationStore = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: {
evaluator,
// Permission composition still runs over the whole discovered inventory, unchanged: a
// disabled module's permissions stay registered and grantable, and nothing enforces them
// because it mounts no routes.
registry: composePlatformPermissionRegistry(modules),
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: {
snapshot: {
discoveredModules: modules,
eligibility: eligibility.entries,
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
},
store,
},
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, store };
}
function owner(): InstallOptions {
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
}
interface LifecycleEntry {
readonly id: string;
readonly version: string;
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
readonly runtime: {
readonly state: string;
readonly reason?: string;
readonly dependency?: { readonly kind: string; readonly moduleId: string };
};
readonly restartRequired: boolean;
}
async function readLifecycle(app: Hono): Promise<readonly LifecycleEntry[]> {
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(200);
const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] };
return body.modules;
}
function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) {
return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, {
body: JSON.stringify({ enabled }),
headers: { "content-type": "application/json", origin },
method: "PUT",
});
}
describe("GET /api/v1/module-lifecycle", () => {
it("reports every discovered module with both axes separated", async () => {
const { app } = await install(owner());
expect(await readLifecycle(app)).toEqual([
{
configured: { enabled: true, explicit: false },
id: "storage",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
},
{
configured: { enabled: true, explicit: false },
id: "announcements",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
},
]);
});
it("explains a disabled module and the dependent it blocks, with a structured reason", async () => {
const { app } = await install({ ...owner(), bootDisabled: ["storage"] });
expect(await readLifecycle(app)).toEqual([
{
configured: { enabled: false, explicit: true },
id: "storage",
restartRequired: false,
runtime: { state: "disabled" },
version: "1.0.0",
},
{
configured: { enabled: true, explicit: false },
id: "announcements",
restartRequired: false,
runtime: {
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
state: "blocked",
},
version: "1.0.0",
},
]);
});
it("is not cached and varies on origin and cookie", async () => {
const { app } = await install(owner());
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(response.headers.get("Vary")).toContain("Cookie");
});
it("refuses an anonymous caller", async () => {
const { app } = await install();
const response = await app.request(MODULE_LIFECYCLE_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => {
// The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this
// resource is deliberately restricted, because it is the only place a disabled module is
// visible at all.
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
const lifecycle = await app.request(MODULE_LIFECYCLE_PATH);
expect(inventory.status).toBe(200);
expect(lifecycle.status).toBe(403);
});
it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => {
const { app } = await install({
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
// Holding it confers no module authority: the Dashboard's own manage permission is untouched by
// this grant, so a lifecycle administrator cannot reorganize navigation.
const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
body: JSON.stringify({ label: "Media", order: 10 }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(reorganize.status).toBe(403);
});
it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => {
const failing: ModuleLifecycleStore = {
readConfiguration: async () => {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED");
},
setEnabled: async () => {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
},
};
// Boot with a working store, then swap in the failing one for the request path only.
const { app } = await install({ ...owner(), store: failingAfterBoot(failing) });
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" });
});
});
/** Answers the one boot-time read with an empty configuration, then fails every later call — so a
* request-path failure can be exercised without making startup composition itself impossible. */
function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore {
let bootRead = false;
return {
async readConfiguration() {
if (!bootRead) {
bootRead = true;
return { modules: [] };
}
return failing.readConfiguration();
},
setEnabled: (input) => failing.setEnabled(input),
};
}
describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => {
it("persists a decision and reports honestly that the running process has not changed", async () => {
const { app } = await install(owner());
const response = await setEnabled(app, "announcements", false);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
configured: { enabled: false, explicit: true },
id: "announcements",
// Not `disabled`: this process is still serving that module. Claiming otherwise would be a
// lie about a module that is still running.
restartRequired: true,
runtime: { state: "running" },
version: "1.0.0",
});
});
it("marks the dependents of a newly disabled module as restart-pending too", async () => {
const { app } = await install(owner());
await setEnabled(app, "storage", false);
expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([
["storage", true],
["announcements", true],
]);
});
it("is idempotent, and re-enabling clears the pending restart", async () => {
const { app } = await install(owner());
await setEnabled(app, "announcements", false);
await setEnabled(app, "announcements", false);
const reEnabled = await setEnabled(app, "announcements", true);
expect(await reEnabled.json()).toEqual({
configured: { enabled: true, explicit: true },
id: "announcements",
restartRequired: false,
runtime: { state: "running" },
version: "1.0.0",
});
});
it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "not-installed", false);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("refuses a malformed module identifier without reaching the store", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "Not%20A%20Module", false);
expect(response.status).toBe(404);
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("rejects an untrusted origin before authorization or the store runs", async () => {
const { app, store } = await install(owner());
const response = await setEnabled(app, "announcements", false, untrustedOrigin);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("rejects a missing origin", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: JSON.stringify({ enabled: false }),
headers: { "content-type": "application/json" },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("refuses an authenticated caller without the restricted permission", async () => {
const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
const response = await setEnabled(app, "announcements", false);
expect(response.status).toBe(403);
expect((await store.readConfiguration()).modules).toEqual([]);
});
it.each([
["a non-boolean value", { enabled: "false" }],
["a missing field", {}],
["an unexpected key alongside a valid one", { enabled: false, force: true }],
["an array", []],
["a bare string", "disabled"],
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
const { app, store } = await install(owner());
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect((await store.readConfiguration()).modules).toEqual([]);
});
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
const { app } = await install(owner());
const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) });
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
body: oversized,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
it("never leaks a filesystem path, package name, or manifest path through either verb", async () => {
const { app } = await install(owner());
const listed = await (
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
).text();
const mutated = await (await setEnabled(app, "announcements", false)).text();
for (const payload of [listed, mutated]) {
expect(payload).not.toContain("/tmp");
expect(payload).not.toContain("@test/module-");
expect(payload).not.toContain("ekklesia.module.json");
expect(payload).not.toContain("dist/");
expect(payload).not.toContain("entrypoint");
}
});
});
describe("lifecycle state governs module UI exposure", () => {
it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => {
const { app } = await install({
bootDisabled: ["announcements"],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as {
readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[];
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
};
// Discovered, validated, and declaring a real compiled UI artifact — and still absent, because
// publication is built from the eligible set rather than the discovered one.
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]);
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]);
expect(navigation.groups).toEqual([]);
});
it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => {
const { app } = await install({
bootDisabled: ["storage"],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
expect(inventory.modules).toEqual([]);
});
it("does not remove a module from navigation merely because a restart is pending", async () => {
const { app } = await install({
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
await setEnabled(app, "announcements", false);
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
readonly modules: readonly { readonly id: string }[];
};
// The module really is still mounted and still serving; hiding its UI now would break a viewer
// mid-session for a change that has not taken effect. `restartRequired` is where that pending
// change is reported.
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]);
});
});
describe("the lifecycle prefix is a sibling of the module route namespace", () => {
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
// `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named
// `announcements` may legitimately register `PUT /enabled` — mounted at exactly
// `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation
// there, registration order would decide which one answers.
expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false);
expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle");
const { app } = await install(owner());
const collision = await app.request("/api/v1/modules/announcements/enabled", {
body: JSON.stringify({ enabled: false }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(collision.status).toBe(404);
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
});
});
+528
View File
@@ -0,0 +1,528 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRealtimePublisher,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
type AuthorizationEvaluator,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
discoverModules,
type ModuleRealtimeRegistry,
type ModuleRealtimeSubscription,
startDiscoveredModuleRuntime,
type StartedModuleRuntime,
} from "@ekklesia/core/modules";
import { type ServerType, serve } from "@hono/node-server";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { mountModuleRealtimeRoutes } from "../src/module-realtime-routes.js";
const allowedUserId = parseEkklesiaUserId("99999999-9999-4999-8999-999999999999");
const deniedUserId = parseEkklesiaUserId("77777777-7777-4777-8777-777777777777");
const stableDeliveryUrl =
"https://objects.example.test/announcements/slides/welcome.png?X-Amz-Signature=stable";
const TRANSPORT_DEADLINE_MS = 2_000;
async function withinTransportDeadline<Value>(
operation: Promise<Value>,
message: string,
onTimeout: () => void = () => undefined,
): Promise<Value> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
onTimeout();
reject(new Error(message));
}, TRANSPORT_DEADLINE_MS);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function waitForTransportCondition(predicate: () => boolean, message: string): Promise<void> {
let cancelled = false;
await withinTransportDeadline(
new Promise<void>((resolve) => {
const inspect = (): void => {
if (cancelled) {
return;
}
if (predicate()) {
resolve();
return;
}
setTimeout(inspect, 5);
};
inspect();
}),
message,
() => {
cancelled = true;
},
);
}
interface FixtureControl {
readonly events: string[];
publisher?: ModuleRealtimePublisher;
}
interface LiveEvent {
readonly event: "snapshot" | "update";
readonly payload: unknown;
readonly sequence: number;
}
class LiveSseConnection {
readonly #abort: AbortController;
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
#buffer = "";
constructor(response: Response, abort: AbortController) {
if (!response.body) {
throw new Error("Realtime integration response had no body.");
}
this.#abort = abort;
this.#reader = response.body.getReader();
}
async readEvent(): Promise<LiveEvent> {
for (;;) {
const boundary = this.#buffer.indexOf("\n\n");
if (boundary >= 0) {
const block = this.#buffer.slice(0, boundary);
this.#buffer = this.#buffer.slice(boundary + 2);
if (block.startsWith(":")) {
continue;
}
const event = block
.split("\n")
.find((line) => line.startsWith("event: "))
?.slice("event: ".length);
const data = block
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice("data: ".length);
if ((event !== "snapshot" && event !== "update") || data === undefined) {
throw new Error("Realtime integration received an invalid event.");
}
const envelope = JSON.parse(data) as { payload?: unknown; sequence?: unknown };
if (!Number.isSafeInteger(envelope.sequence) || (envelope.sequence as number) <= 0) {
throw new Error("Realtime integration received an invalid sequence.");
}
return { event, payload: envelope.payload, sequence: envelope.sequence as number };
}
const result = await this.#readWithinDeadline();
if (result.done) {
throw new Error("Realtime integration stream closed before the expected event.");
}
this.#buffer += new TextDecoder().decode(result.value, { stream: true });
}
}
async expectClosed(): Promise<void> {
const result = await this.#readWithinDeadline();
expect(result.done).toBe(true);
}
async close(): Promise<void> {
this.#abort.abort();
await withinTransportDeadline(
// Abort and cancel race by design; a rejection means one of them already released the
// transport. A deadline still propagates and fails the test.
this.#reader.cancel().catch(() => undefined),
"Realtime integration client cancellation timed out.",
);
}
async #readWithinDeadline() {
return withinTransportDeadline(
this.#reader.read(),
"Realtime integration read timed out.",
() => this.#abort.abort(),
);
}
}
async function createFixtureModule(control: FixtureControl): Promise<{
readonly directory: string;
readonly symbol: symbol;
}> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-native-realtime-"));
const moduleDirectory = path.join(directory, "announcements");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
const symbol = Symbol.for(`ekklesia.native-realtime.${Date.now()}.${Math.random()}`);
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = control;
const expression = `globalThis[Symbol.for(${JSON.stringify(Symbol.keyFor(symbol))})]`;
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: "@test/module-announcements",
type: "module",
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id: "announcements",
manifestVersion: 7,
name: "Announcements fixture",
permissions: [
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: "announcements.slideshow.view",
},
],
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "announcements",
version: "0.1.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: "announcements.slideshow.view" });
${expression}.events.push("register");
},
start({ realtime }) {
${expression}.publisher = realtime;
realtime.publish("state", {
mode: "on",
currentSlide: 0,
previousSlide: -1,
slides: [${JSON.stringify(stableDeliveryUrl)}]
});
${expression}.events.push("start");
},
stop() { ${expression}.events.push("stop"); }
};\n`,
),
]);
return { directory, symbol };
}
function services(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
}
function identityRuntime(): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: async (headers) => {
const cookie = headers.get("cookie");
if (cookie === "ekklesia-session=allowed") {
return createAuthenticatedPrincipal(allowedUserId);
}
if (cookie === "ekklesia-session=denied") {
return createAuthenticatedPrincipal(deniedUserId);
}
return ANONYMOUS_PRINCIPAL;
},
};
}
function authorization(): AuthorizationEvaluator {
return createAuthorizationEvaluator({
grants: { hasActiveGrant: async ({ userId }) => userId === allowedUserId },
platformOwners: { isPlatformOwner: async () => false },
});
}
async function bindLiveServer(
runtime: StartedModuleRuntime,
registry: PermissionRegistry,
): Promise<{
readonly activeSubscriptions: () => number;
readonly origin: string;
readonly server: ServerType;
}> {
const app = new Hono();
let activeSubscriptions = 0;
const observedRealtime: ModuleRealtimeRegistry = {
channels: runtime.realtime.channels,
closeSubscriptions: () => runtime.realtime.closeSubscriptions(),
publisherFor: (moduleId) => runtime.realtime.publisherFor(moduleId),
subscribe: (moduleId, channel): ModuleRealtimeSubscription => {
const subscription = runtime.realtime.subscribe(moduleId, channel);
activeSubscriptions += 1;
let closed = false;
return Object.freeze({
close: (): void => {
if (!closed) {
closed = true;
activeSubscriptions -= 1;
}
subscription.close();
},
read: () => subscription.read(),
snapshot: subscription.snapshot,
});
},
};
mountModuleRealtimeRoutes(app, {
evaluator: authorization(),
realtime: observedRealtime,
registry,
runtime: identityRuntime(),
});
app.notFound((c) => c.json({ code: "NOT_FOUND" }, 404));
let pendingServer: ServerType | undefined;
const server = await withinTransportDeadline(
new Promise<ServerType>((resolve, reject) => {
pendingServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, () =>
resolve(pendingServer as ServerType),
);
pendingServer.once("error", reject);
}),
"Realtime integration server bind timed out.",
() => pendingServer?.close(),
);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Realtime integration server did not bind a TCP port.");
}
return {
activeSubscriptions: () => activeSubscriptions,
origin: `http://127.0.0.1:${address.port}`,
server,
};
}
async function connect(
origin: string,
cookie: string,
): Promise<{
readonly connection: LiveSseConnection;
readonly response: Response;
}> {
const abort = new AbortController();
const response = await withinTransportDeadline(
fetch(`${origin}/api/v1/module-realtime/announcements/state`, {
headers: { cookie },
signal: abort.signal,
}),
"Realtime integration connection timed out.",
() => abort.abort(),
);
return { connection: new LiveSseConnection(response, abort), response };
}
async function closeServer(server: ServerType): Promise<void> {
await withinTransportDeadline(
new Promise<void>((resolve) => server.close(() => resolve())),
"Realtime integration server close timed out.",
() =>
(
server as ServerType & {
closeAllConnections?: () => void;
}
).closeAllConnections?.(),
);
}
async function requestJson(
url: string,
init?: RequestInit,
): Promise<{ readonly body: unknown; readonly response: Response }> {
const abort = new AbortController();
const operation = (async (): Promise<{ readonly body: unknown; readonly response: Response }> => {
const response = await fetch(url, { ...init, signal: abort.signal });
const body = (await response.json()) as unknown;
return { body, response };
})();
return withinTransportDeadline(operation, "Realtime integration finite request timed out.", () =>
abort.abort(),
);
}
describe("native realtime over a live Node HTTP server", () => {
it("loads a real module and proves auth, snapshot, fanout, update, reconnect, and shutdown", async () => {
const control: FixtureControl = { events: [] };
const fixture = await createFixtureModule(control);
let runtime: StartedModuleRuntime | undefined;
let server: ServerType | undefined;
const connections: LiveSseConnection[] = [];
const failures: unknown[] = [];
try {
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory: fixture.directory,
platformVersion: "0.1.0",
});
runtime = await startDiscoveredModuleRuntime(modules, services());
expect(control.events).toEqual(["register", "start"]);
const live = await bindLiveServer(runtime, composePlatformPermissionRegistry(modules));
server = live.server;
const anonymous = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
);
expect(anonymous.response.status).toBe(403);
expect(anonymous.body).toEqual({ code: "AUTHORIZATION_DENIED" });
const missingPermission = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
{ headers: { cookie: "ekklesia-session=denied" } },
);
expect(missingPermission.response.status).toBe(403);
const first = await connect(live.origin, "ekklesia-session=allowed");
const second = await connect(live.origin, "ekklesia-session=allowed");
connections.push(first.connection, second.connection);
expect(first.response.status).toBe(200);
expect(first.response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
const expectedInitial = {
currentSlide: 0,
mode: "on",
previousSlide: -1,
slides: [stableDeliveryUrl],
};
expect(await first.connection.readEvent()).toEqual({
event: "snapshot",
payload: expectedInitial,
sequence: 1,
});
expect(await second.connection.readEvent()).toEqual({
event: "snapshot",
payload: expectedInitial,
sequence: 1,
});
expect(live.activeSubscriptions()).toBe(2);
control.publisher?.publish("state", {
currentSlide: 1,
mode: "on",
previousSlide: 0,
slides: [stableDeliveryUrl],
});
const expectedUpdate = {
event: "update",
payload: {
currentSlide: 1,
mode: "on",
previousSlide: 0,
slides: [stableDeliveryUrl],
},
sequence: 2,
};
expect(await first.connection.readEvent()).toEqual(expectedUpdate);
expect(await second.connection.readEvent()).toEqual(expectedUpdate);
await first.connection.close();
await waitForTransportCondition(
() => live.activeSubscriptions() === 1,
"Realtime integration subscriber cleanup timed out.",
);
control.publisher?.publish("state", {
currentSlide: 2,
mode: "off",
previousSlide: 1,
slides: [stableDeliveryUrl],
});
expect(await second.connection.readEvent()).toMatchObject({ event: "update", sequence: 3 });
const reconnected = await connect(live.origin, "ekklesia-session=allowed");
connections.push(reconnected.connection);
expect(await reconnected.connection.readEvent()).toEqual({
event: "snapshot",
payload: {
currentSlide: 2,
mode: "off",
previousSlide: 1,
slides: [stableDeliveryUrl],
},
sequence: 3,
});
expect(live.activeSubscriptions()).toBe(2);
const secondClosed = second.connection.expectClosed();
const reconnectedClosed = reconnected.connection.expectClosed();
runtime.realtime.closeSubscriptions();
await Promise.all([secondClosed, reconnectedClosed]);
await waitForTransportCondition(
() => live.activeSubscriptions() === 0,
"Realtime integration shutdown cleanup timed out.",
);
// Stream shutdown happens before ordinary HTTP drain in production. A publisher must remain
// usable until that drain completes so an already-running command can publish its result.
expect(() => control.publisher?.publish("state", null)).not.toThrow();
const afterShutdown = await requestJson(
`${live.origin}/api/v1/module-realtime/announcements/state`,
{ headers: { cookie: "ekklesia-session=allowed" } },
);
expect(afterShutdown.response.status).toBe(503);
expect(afterShutdown.body).toEqual({ code: "REALTIME_UNAVAILABLE" });
await runtime.stop();
expect(() => control.publisher?.publish("state", null)).toThrowError(
expect.objectContaining({ code: "REALTIME_PUBLISHER_STOPPED" }),
);
} catch (error) {
failures.push(error);
} finally {
try {
runtime?.realtime.closeSubscriptions();
} catch (error) {
failures.push(error);
}
const connectionCleanup = await Promise.allSettled(
connections.map((connection) => connection.close()),
);
for (const result of connectionCleanup) {
if (result.status === "rejected") {
failures.push(result.reason);
}
}
if (server) {
try {
await closeServer(server);
} catch (error) {
failures.push(error);
}
}
if (runtime) {
try {
await runtime.stop();
} catch (error) {
failures.push(error);
}
}
delete (globalThis as unknown as Record<PropertyKey, unknown>)[fixture.symbol];
try {
await rm(fixture.directory, { force: true, recursive: true });
} catch (error) {
failures.push(error);
}
}
if (failures.length > 0) {
throw failures[0];
}
expect(control.events).toEqual(["register", "start", "stop"]);
});
});
@@ -0,0 +1,270 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
ModuleRealtimeError,
moduleIdSchema,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import { createPermissionRegistry, type PermissionRegistry } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { type ModuleRealtimeRegistry, StagedModuleRealtimeRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import {
MODULE_REALTIME_KEEPALIVE_MS,
MODULE_REALTIME_MAX_CONNECTION_AGE_MS,
ModuleRealtimeConfigurationError,
moduleRealtimeBaseUrl,
mountModuleRealtimeRoutes,
} from "../src/module-realtime-routes.js";
const moduleId = moduleIdSchema.parse("announcements");
const permission = parsePermissionId("announcements.slideshow.view");
const userId = parseEkklesiaUserId("88888888-8888-4888-8888-888888888888");
const endpoint = "/api/v1/module-realtime/announcements/state";
function permissionRegistry(): PermissionRegistry {
return createPermissionRegistry([
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: permission,
},
]);
}
function realtimeFixture(initialPayload: unknown = { mode: "on" }): {
readonly publisher: ReturnType<StagedModuleRealtimeRegistry["publisherFor"]>;
readonly realtime: ModuleRealtimeRegistry;
} {
const staged = new StagedModuleRealtimeRegistry();
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
registration.realtime.channel({ channel: "state", permission });
registration.commit();
const publisher = staged.publisherFor(moduleId);
publisher.publish("state", initialPayload as never);
return { publisher, realtime: staged.publish() };
}
function appFor(
realtime: ModuleRealtimeRegistry,
options: {
readonly allowed?: boolean;
readonly keepaliveMs?: number;
readonly maxConnectionAgeMs?: number;
readonly signedIn?: boolean;
} = {},
): Hono {
const app = new Hono();
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi
.fn()
.mockResolvedValue(
options.signedIn === false ? ANONYMOUS_PRINCIPAL : createAuthenticatedPrincipal(userId),
),
};
mountModuleRealtimeRoutes(app, {
evaluator: {
authorize: vi
.fn()
.mockResolvedValue({ allowed: options.signedIn !== false && options.allowed !== false }),
},
...(options.keepaliveMs === undefined ? {} : { keepaliveMs: options.keepaliveMs }),
...(options.maxConnectionAgeMs === undefined
? {}
: { maxConnectionAgeMs: options.maxConnectionAgeMs }),
realtime,
registry: permissionRegistry(),
runtime,
});
return app;
}
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
reader.read(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Realtime test read timed out.")), 1_000);
}),
]);
return result.done ? "" : new TextDecoder().decode(result.value);
} finally {
clearTimeout(timer);
}
}
describe("module realtime HTTP transport", () => {
it("builds one server-owned root and rejects an unenforceable channel snapshot", () => {
expect(moduleRealtimeBaseUrl("announcements")).toBe("/api/v1/module-realtime/announcements");
expect(MODULE_REALTIME_KEEPALIVE_MS).toBe(15_000);
expect(MODULE_REALTIME_MAX_CONNECTION_AGE_MS).toBe(300_000);
const { realtime } = realtimeFixture();
expect(() =>
mountModuleRealtimeRoutes(new Hono(), {
evaluator: { authorize: vi.fn() },
realtime,
registry: createPermissionRegistry([]),
runtime: { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn() },
}),
).toThrow(ModuleRealtimeConfigurationError);
});
it("denies anonymous and unauthorized callers before allocating a subscription", async () => {
const { realtime } = realtimeFixture();
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe,
};
const anonymous = await appFor(observed, { signedIn: false }).request(endpoint);
expect(anonymous.status).toBe(403);
expect(await anonymous.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(subscribe).not.toHaveBeenCalled();
const unauthorized = await appFor(observed, { allowed: false }).request(endpoint);
expect(unauthorized.status).toBe(403);
expect(await unauthorized.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(subscribe).not.toHaveBeenCalled();
});
it("refuses Hono's implicit HEAD handling without allocating a stream subscription", async () => {
const { realtime } = realtimeFixture();
const subscribe = vi.fn(realtime.subscribe.bind(realtime));
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe,
};
const response = await appFor(observed).request(endpoint, { method: "HEAD" });
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(subscribe).not.toHaveBeenCalled();
});
it("opens an authorized snapshot-first stream with proxy-safe headers and unchanged URLs", async () => {
const deliveryUrl =
"https://objects.example.test/slides/welcome.png?X-Amz-Signature=byte-identical";
const { realtime } = realtimeFixture({
currentSlide: 0,
mode: "on",
previousSlide: -1,
slides: [deliveryUrl],
});
const response = await appFor(realtime).request(endpoint);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("x-accel-buffering")).toBe("no");
expect(response.headers.get("vary")).toBe("Origin, Cookie");
const reader = response.body?.getReader();
expect(reader).toBeDefined();
const snapshot = await readChunk(reader as ReadableStreamDefaultReader<Uint8Array>);
expect(snapshot).toContain("event: snapshot\n");
expect(snapshot).toContain('"sequence":1');
expect(snapshot).toContain(deliveryUrl);
expect(snapshot).not.toContain("\nid:");
await reader?.cancel();
});
it("streams ordered updates, emits transport-only keepalives, and closes on shutdown", async () => {
const { publisher, realtime } = realtimeFixture({ currentSlide: 0 });
const response = await appFor(realtime, { keepaliveMs: 5 }).request(endpoint);
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
expect(await readChunk(reader)).toContain("event: snapshot");
publisher.publish("state", { currentSlide: 1 });
const update = await readChunk(reader);
expect(update).toContain("event: update");
expect(update).toContain('"sequence":2');
expect(update).toContain('"currentSlide":1');
expect(await readChunk(reader)).toBe(": keepalive\n\n");
const closed = reader.read();
realtime.closeSubscriptions();
await expect(closed).resolves.toEqual({ done: true, value: undefined });
});
it("closes a stream at the host-owned maximum age so reconnect reauthorizes", async () => {
const { realtime } = realtimeFixture({ currentSlide: 0 });
let activeSubscriptions = 0;
const observed: ModuleRealtimeRegistry = {
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe: (owner, channel) => {
const subscription = realtime.subscribe(owner, channel);
activeSubscriptions += 1;
let closed = false;
return Object.freeze({
close: () => {
if (!closed) {
closed = true;
activeSubscriptions -= 1;
}
subscription.close();
},
read: () => subscription.read(),
snapshot: subscription.snapshot,
});
},
};
const response = await appFor(observed, {
keepaliveMs: 1_000,
maxConnectionAgeMs: 25,
}).request(endpoint);
const reader = response.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>;
expect(await readChunk(reader)).toContain("event: snapshot");
expect(activeSubscriptions).toBe(1);
expect(await readChunk(reader)).toBe("");
expect(activeSubscriptions).toBe(0);
const replacement = realtime.subscribe(moduleId, "state");
replacement.close();
});
it("does not expose unknown module/channel combinations", async () => {
const { realtime } = realtimeFixture();
const app = appFor(realtime);
expect((await app.request("/api/v1/module-realtime/calendar/state")).status).toBe(404);
expect((await app.request("/api/v1/module-realtime/announcements/other")).status).toBe(404);
});
it("maps subscriber-capacity and unexpected allocation failures to fixed non-secret responses", async () => {
const { realtime } = realtimeFixture();
const observed = (failure: unknown): ModuleRealtimeRegistry => ({
channels: realtime.channels,
closeSubscriptions: () => realtime.closeSubscriptions(),
publisherFor: (owner) => realtime.publisherFor(owner),
subscribe: () => {
throw failure;
},
});
const atCapacity = await appFor(
observed(new ModuleRealtimeError("REALTIME_SUBSCRIBER_LIMIT")),
).request(endpoint);
expect(atCapacity.status).toBe(503);
expect(await atCapacity.json()).toEqual({ code: "REALTIME_SUBSCRIBER_LIMIT" });
const detail = "secret-token-and-machine-path";
const unavailable = await appFor(observed(new Error(detail))).request(endpoint);
expect(unavailable.status).toBe(503);
const body = await unavailable.text();
expect(body).toBe('{"code":"REALTIME_UNAVAILABLE"}');
expect(body).not.toContain(detail);
});
});
+775
View File
@@ -0,0 +1,775 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules";
import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
} from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import {
bootstrapDisplayHost,
bootstrapHost,
createModuleUiContext,
type EkklesiaHostConfig,
moduleUiSourceUrl,
} from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
/**
* The whole chain, with nothing between the halves faked:
*
* ```text
* module fixture on disk -> discovery -> module runtime started -> publication registry
* -> inventory endpoint -> the shell's own bootstrap -> server-produced artifact URL
* -> the generic loader -> mount -> the module calls its own API relatively
* -> the shell resolves it to /api/v1/modules/<id>/... -> server permission enforcement
* ```
*
* Every step runs real code. The module is discovered and *started* by Core's real runtime, its
* routes are mounted by the real `createApiApp`, its artifact is published by the real ADR 0025
* route, its descriptor comes from the real inventory endpoint, and the browser half is
* `@ekklesia/shell`'s own exported protocol — the same functions the React shell calls — rather
* than a reimplementation of them here.
*
* Two things are deliberately *not* faked-around and are worth naming. The shell's `fetch` is the
* Hono app's own `request`, so requests really traverse routing, CORS, authorization middleware, and
* handlers without needing a socket. And Node cannot `import()` an `http:` URL, so the artifact is
* fetched over that same in-process HTTP path and the **bytes it returned** are handed to the
* generic loader through a `data:` URL — a genuine module URL, the same separation ADR 0025 used.
* No test here reads the artifact off the filesystem and calls that a proof of publication.
*/
const viewerId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
/**
* A real, complete module: a manifest declaring a restricted permission and a UI entrypoint, a
* server entrypoint that registers a permission-gated route, and a compiled UI artifact that reads
* its host context and calls its own API relatively.
*
* The UI artifact is written as plain ESM rather than compiled from TSX here: ADR 0023's pipeline is
* already proven end to end by `templates/module`'s own tests, and what this test needs is a byte
* sequence whose journey through publication, inventory, and the loader can be asserted exactly.
*/
async function createFixtureModule(
modulesDirectory: string,
id = "example",
name = "Example module",
withDisplay = false,
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
if (withDisplay) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
}
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
ui: "./dist/ui/index.js",
...(withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: `${id}.display.view`,
},
}
: {}),
},
id,
manifestVersion: 7,
name,
permissions: [
{
baseline: "restricted",
description: "Read example records.",
id: `${id}.records.read`,
},
...(withDisplay
? [
{
baseline: "restricted",
description: "View the example display.",
id: `${id}.display.view`,
},
]
: []),
],
version: "0.1.0",
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/records",
permission: ${JSON.stringify(`${id}.records.read`)},
maxBodyBytes: 0,
async handler() {
return Response.json([{ id: "a" }, { id: "b" }]);
},
});
},
};\n`,
);
// The module UI: host-neutral, asks for a relative path, and renders from the host context. It
// hardcodes no origin, no prefix, and no permission decision of its own.
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)},
version: "0.1.0",
async mount(container, context) {
if (!context.permissions.has(${JSON.stringify(`${id}.records.read`)})) {
container.textContent = context.moduleId + ":denied";
return () => { container.textContent = ""; };
}
const response = await context.api.request("records");
const records = response.ok ? await response.json() : [];
container.textContent =
context.moduleId + ":" + response.status + ":" + records.length;
return () => { container.textContent = ""; };
},
};\n`,
);
if (withDisplay) {
await writeFile(
path.join(moduleDirectory, "dist/display/index.js"),
`export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)},
version: "0.1.0",
async mount(container, context) {
const response = await context.api.request("records");
const records = response.ok ? await response.json() : [];
container.textContent =
context.moduleId + ":display:" + response.status + ":" + records.length;
return () => { container.textContent = ""; };
},
};\n`,
);
}
}
interface HarnessOptions {
readonly display?: boolean;
readonly principal?: EkklesiaPrincipal;
readonly grants?: readonly string[];
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
* single module proves very little. */
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
readonly platformOwner?: boolean;
}
interface Harness {
readonly config: EkklesiaHostConfig;
readonly moduleApiCalls: readonly string[];
stop(): Promise<void>;
}
/**
* Starts a real API over a real discovered-and-started module, and returns the `EkklesiaHostConfig`
* a browser would use — with `fetch` wired to the app itself.
*/
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
temporaryDirectories.push(modulesDirectory);
await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
for (const extra of options.extraModules ?? []) {
await createFixtureModule(modulesDirectory, extra.id, extra.name);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
// route snapshot below is the one it actually published.
const moduleRuntime = await startDiscoveredModuleRuntime(modules, services);
const granted = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const store = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const moduleUi = createModuleUiPublicationRegistry(modules);
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services,
moduleUi,
...(moduleUi.listDisplays().length === 0
? {}
: {
displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
"export const displayHost = true;\n",
"export const displayWorker = true;\n",
),
}),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
});
const moduleApiCalls: string[] = [];
const hostFetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/api/v1/modules/")) {
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
}
return app.request(url.href, init);
}) as unknown as typeof fetch;
return {
config: { apiBaseUrl: "https://ekklesia.example.test", fetch: hostFetch },
moduleApiCalls,
stop: () => moduleRuntime.stop(),
};
}
/** Node cannot `import()` an `http:` URL, so the artifact is fetched over the real route and its
* exact bytes become a `data:` URL. The loader still performs a genuine dynamic import of a real
* module URL; only the transport differs, and the transport itself is asserted separately. */
async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promise<string> {
const response = await (harness.config.fetch as typeof fetch)(sourceUrl);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
const bytes = Buffer.from(await response.arrayBuffer());
return `data:text/javascript;base64,${bytes.toString("base64")}`;
}
/**
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
* test environment.
*
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
* dynamically imports real module server entrypoints from temporary directories, which then fail to
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
* this server-side suite depends on exactly as Node provides it.
*/
function containerSink(): HostContainer {
return new Window().document.createElement("div") as unknown as HostContainer;
}
interface HostContainer {
readonly children: { readonly length: number };
querySelector(selectors: string): HostContainer | null;
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
}
/** What the mounted artifact rendered, read through its open isolated root. */
function mountedText(container: HostContainer): string {
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
}
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
describe("end to end: a discovered module's UI mounts in the Ekklesia host", () => {
it("bootstraps, publishes, loads, mounts, and reaches the module's own API", async () => {
const harness = await startHarness({
grants: ["example.records.read"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
// 1. The shell's own bootstrap: principal, then inventory.
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId });
// A module nobody has arranged yet is ungrouped and visible — no initialization write.
expect(bootstrap.navigation.groups).toEqual([]);
expect(bootstrap.navigation.ungrouped).toHaveLength(1);
expect(bootstrap.navigation.ungrouped[0]?.name).toBe("Example module");
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
// 2. The URLs came from the server, not from anything the browser composed.
expect(descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
contributions: [],
id: "example",
permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0",
version: "0.1.0",
});
// 3. The artifact really is served by the real publication route at that URL.
const sourceUrl = moduleUiSourceUrl(harness.config, descriptor);
expect(sourceUrl).toBe("https://ekklesia.example.test/api/v1/module-ui/example/0.1.0");
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader mounts it with the context the shell built.
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
}),
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
});
// 5. The module rendered, having reached its own backend through the host.
expect(mountedText(container)).toBe("example:200:2");
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
// 7. And it tears down cleanly.
await mounted.unmount();
expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false);
} finally {
await harness.stop();
}
});
it("opens the stable display route, resolves its real descriptor, and mounts the published display bytes", async () => {
const harness = await startHarness({
display: true,
grants: ["example.records.read", "example.display.view"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
// 1. A device keeps this version-independent host URL. It is a real permission-gated API route,
// not a test-only HTML wrapper around the browser functions below.
const page = await (harness.config.fetch as typeof fetch)(
"https://ekklesia.example.test/display/example",
);
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(await page.text()).toContain("/assets/ekklesia-display/app.js");
// 2. The display surface's own bootstrap resolves principal first, then the singular protected
// descriptor. The browser constructs no artifact, API, or realtime URL itself.
const bootstrap = await bootstrapDisplayHost(harness.config, "example");
expect(bootstrap.status).toBe("ready");
if (bootstrap.status !== "ready") {
throw new Error("the authenticated display did not resolve");
}
expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
contributions: [],
displayPageUrl: "/display/example",
id: "example",
permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0/display",
version: "0.1.0",
});
// 3. Fetch the exact role-specific bytes from the real publication route, then genuinely import
// those returned bytes. Reading the fixture file directly would not prove publication.
const sourceUrl = moduleUiSourceUrl(harness.config, bootstrap.descriptor);
expect(sourceUrl).toBe(
"https://ekklesia.example.test/api/v1/module-ui/example/0.1.0/display",
);
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader receives the host-owned browser context, including the canonical
// display page, and mounts the display artifact independently of the Dashboard artifact.
const context = createModuleUiContext({
config: harness.config,
descriptor: bootstrap.descriptor,
principal: bootstrap.principal,
});
expect(Object.keys(context).sort()).toEqual([
"api",
"contributions",
"display",
"moduleId",
"permissions",
"principal",
"realtime",
]);
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context,
source: {
moduleId: bootstrap.descriptor.id,
url: artifactUrl,
version: bootstrap.descriptor.version,
},
});
expect(mountedText(container)).toBe("example:display:200:2");
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
await mounted.unmount();
expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false);
} finally {
await harness.stop();
}
});
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
// Signed in, but holding no grant for the module's restricted permission.
const harness = await startHarness({
grants: [],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
// The hint is honest about it...
expect(descriptor.permissions).toEqual([]);
const context = createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
});
expect(context.permissions.has("example.records.read")).toBe(false);
// ...and the hint is not what protects the route. Calling anyway — exactly what a buggy or
// hostile UI would do — is still refused by the server.
const response = await context.api.request("records");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
} finally {
await harness.stop();
}
});
it("renders the module's own denied state from the hint, without calling the API", async () => {
const harness = await startHarness({
grants: [],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
const artifactUrl = await publishedArtifactUrl(
harness,
moduleUiSourceUrl(harness.config, descriptor),
);
const container = containerSink();
await loadModuleUi({
container: container as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
}),
source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version },
});
// Hiding a control is a usability improvement: the module simply did not ask.
expect(mountedText(container)).toBe("example:denied");
expect(harness.moduleApiCalls).toEqual([]);
} finally {
await harness.stop();
}
});
it("renders a configured arrangement, and a move changes presentation only", async () => {
const harness = await startHarness({
extraModules: [
{ id: "video", name: "Video" },
{ id: "calendar", name: "Calendar" },
],
grants: ["example.records.read"],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
const organize = (url: string, body: unknown) =>
(harness.config.fetch as typeof fetch)(`https://ekklesia.example.test${url}`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
method: "PUT",
});
try {
// An administrator organizes the Dashboard, through the real mutation API.
await organize("/api/v1/dashboard/navigation/groups/media", { label: "Media", order: 10 });
await organize("/api/v1/dashboard/navigation/groups/planning", {
label: "Planning",
order: 20,
});
await organize("/api/v1/dashboard/navigation/placements/video", {
groupId: "media",
order: 10,
});
await organize("/api/v1/dashboard/navigation/placements/example", {
groupId: "media",
order: 20,
});
await organize("/api/v1/dashboard/navigation/placements/calendar", {
groupId: "planning",
order: 10,
});
// The shell's own bootstrap sees exactly that arrangement.
const before = await bootstrapHost(harness.config);
expect(
before.navigation.groups.map((group) => [
group.label,
group.modules.map((entry) => entry.name),
]),
).toEqual([
["Media", ["Video", "Example module"]],
["Planning", ["Calendar"]],
]);
expect(before.navigation.ungrouped).toEqual([]);
// Mounting from inside a group uses the descriptor the group carried, untouched.
const groupedEntry = before.navigation.groups[0]?.modules[1];
if (!groupedEntry) {
throw new Error("the arrangement did not contain the expected module");
}
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor: groupedEntry.descriptor,
principal: before.principal,
}),
source: {
moduleId: groupedEntry.descriptor.id,
url: await publishedArtifactUrl(
harness,
moduleUiSourceUrl(harness.config, groupedEntry.descriptor),
),
version: groupedEntry.descriptor.version,
},
});
expect(mountedText(container)).toBe("example:200:2");
await mounted.unmount();
// Now move it somewhere else entirely.
await organize("/api/v1/dashboard/navigation/groups/sunday-production", {
label: "Sunday Production",
order: 5,
});
await organize("/api/v1/dashboard/navigation/placements/example", {
groupId: "sunday-production",
order: 10,
});
const after = await bootstrapHost(harness.config);
expect(
after.navigation.groups.map((group) => [
group.label,
group.modules.map((entry) => entry.name),
]),
).toEqual([
["Sunday Production", ["Example module"]],
["Media", ["Video"]],
["Planning", ["Calendar"]],
]);
const movedEntry = after.navigation.groups[0]?.modules[0];
if (!movedEntry) {
throw new Error("the moved module was not found in its new group");
}
// The whole point of the pass: the module is byte-identical across the move — same artifact
// URL, same API root, same permissions, same version, same name.
expect(movedEntry).toEqual(groupedEntry);
// And it still mounts the same artifact and reaches the same API.
const callsBeforeRemount = harness.moduleApiCalls.length;
const movedContainer = containerSink();
const remounted = await loadModuleUi({
container: movedContainer as unknown as LoaderContainer,
context: createModuleUiContext({
config: harness.config,
descriptor: movedEntry.descriptor,
principal: after.principal,
}),
source: {
moduleId: movedEntry.descriptor.id,
url: await publishedArtifactUrl(
harness,
moduleUiSourceUrl(harness.config, movedEntry.descriptor),
),
version: movedEntry.descriptor.version,
},
});
expect(mountedText(movedContainer)).toBe("example:200:2");
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
"GET /api/v1/modules/example/records",
]);
await remounted.unmount();
} finally {
await harness.stop();
}
});
it("keeps a newly installed module visible beside an existing arrangement", async () => {
const harness = await startHarness({
extraModules: [{ id: "video", name: "Video" }],
platformOwner: true,
principal: createAuthenticatedPrincipal(viewerId),
});
try {
await (harness.config.fetch as typeof fetch)(
"https://ekklesia.example.test/api/v1/dashboard/navigation/groups/media",
{
body: JSON.stringify({ label: "Media", order: 10 }),
headers: {
"content-type": "application/json",
origin: "https://dashboard.example.test",
},
method: "PUT",
},
);
await (harness.config.fetch as typeof fetch)(
"https://ekklesia.example.test/api/v1/dashboard/navigation/placements/video",
{
body: JSON.stringify({ groupId: "media", order: 10 }),
headers: {
"content-type": "application/json",
origin: "https://dashboard.example.test",
},
method: "PUT",
},
);
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
// `example` was never placed, and is still reachable rather than invisible.
expect(bootstrap.navigation.ungrouped.map((entry) => entry.name)).toEqual(["Example module"]);
} finally {
await harness.stop();
}
});
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
const harness = await startHarness();
try {
const bootstrap = await bootstrapHost(harness.config);
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] });
} finally {
await harness.stop();
}
});
it("refuses a module UI that tries to address another module's API through the host", async () => {
const harness = await startHarness({
grants: ["example.records.read"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const bootstrap = await bootstrapHost(harness.config);
const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor;
if (!descriptor) {
throw new Error("the inventory returned no module");
}
const context = createModuleUiContext({
config: harness.config,
descriptor,
principal: bootstrap.principal,
});
await expect(context.api.request("../../grants?userId=someone")).rejects.toMatchObject({
code: "MODULE_API_PATH_INVALID",
});
// It never became a request at all.
expect(harness.moduleApiCalls).toEqual([]);
} finally {
await harness.stop();
}
});
});
+630
View File
@@ -0,0 +1,630 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly version?: string;
/** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */
readonly withUi?: boolean;
readonly permissions?: ReadonlyArray<{
readonly id: string;
readonly baseline: "authenticated" | "public" | "restricted";
}>;
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
readonly contributions?: readonly {
readonly localName: string;
readonly capability: string;
readonly capabilityRange: string;
}[];
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const version = fixture.version ?? "0.1.0";
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version,
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
},
capabilities: fixture.capabilities ?? [],
contributions: (fixture.contributions ?? []).map((contribution) => ({
artifact: `./dist/contributions/${contribution.localName}/index.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${fixture.id}.${contribution.localName}`,
version: "1.0.0",
})),
id: fixture.id,
manifestVersion: 7,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
description: `Test permission for ${fixture.id}.`,
})),
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
);
if (fixture.withUi) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
);
}
for (const contribution of fixture.contributions ?? []) {
const contributionDirectory = path.join(
moduleDirectory,
"dist/contributions",
contribution.localName,
);
await mkdir(contributionDirectory, { recursive: true });
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};\n");
}
}
interface InstallOptions {
readonly fixtures: readonly ModuleFixture[];
/** Anonymous by default, so every scenario has to opt in to being signed in. */
readonly principal?: EkklesiaPrincipal;
/** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so
* baselines, the owner bypass, and deny-by-default all behave exactly as in production. */
readonly grants?: readonly string[];
readonly platformOwner?: boolean;
/** Makes the grant port fail, so the "a hint that could not be determined is never reported as
* denied" path can be exercised. */
readonly grantsUnavailable?: boolean;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly modulesDirectory: string;
readonly permissionRegistry: PermissionRegistry;
}
/** A real installation: fixtures on disk, Core's real discovery, real permission composition, the
* real publication registry, the real authorization evaluator, and the real `createApiApp`. Only
* identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL
* to answer the questions these tests ask. */
async function install(options: InstallOptions): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const granted = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: {
hasActiveGrant: async (query) => {
if (options.grantsUnavailable) {
throw new Error("grant store unavailable");
}
return granted.has(query.permission);
},
},
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const store = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
const permissionRegistry = composePlatformPermissionRegistry(modules);
const app = createApiApp({
authorization: { evaluator, registry: permissionRegistry, store },
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, modulesDirectory, permissionRegistry };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
async function inventoryOf(app: Hono): Promise<{
readonly body: string;
readonly modules: ReadonlyArray<Record<string, unknown>>;
readonly response: Response;
}> {
const response = await app.request(MODULE_UI_INVENTORY_PATH);
const body = await response.clone().text();
const parsed = (await response.json()) as { modules?: ReadonlyArray<Record<string, unknown>> };
return { body, modules: parsed.modules ?? [], response };
}
describe("module UI inventory — what it lists", () => {
it("describes a publishable module with exactly the fields a host needs", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [], withUi: true }],
principal: signedIn(),
});
const { modules, response } = await inventoryOf(app);
expect(response.status).toBe(200);
expect(modules).toEqual([
{
contributions: [],
api: { baseUrl: "/api/v1/modules/example" },
display: null,
id: "example",
permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/example" },
ui: { url: "/api/v1/module-ui/example/0.1.0" },
version: "0.1.0",
},
]);
});
it("builds all host URLs with the server's own canonical helpers", async () => {
const { app, modules: discovered } = await install({
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
principal: signedIn(),
});
const module = discovered[0] as DiscoveredModule;
const { modules } = await inventoryOf(app);
// Not "looks like the right shape" — byte-identical to what the helpers produce, so a second
// hand-written template string anywhere would fail here.
expect(modules[0]?.ui).toEqual({
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
});
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
expect(modules[0]?.realtime).toEqual({
baseUrl: moduleRealtimeBaseUrl(module.manifest.id),
});
});
it("omits a module that declares no UI entrypoint", async () => {
const { app } = await install({
fixtures: [{ id: "calendar" }, { id: "example", withUi: true }],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.map((module) => module.id)).toEqual(["example"]);
});
it("is empty, not an error, when no installed module provides a UI", async () => {
const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() });
const { modules, response } = await inventoryOf(app);
expect(response.status).toBe(200);
expect(modules).toEqual([]);
});
it("orders modules deterministically, matching the publication registry", async () => {
const { app } = await install({
fixtures: [
{ id: "signage", withUi: true },
{ id: "announcements", withUi: true },
{ id: "calendar", withUi: true },
],
principal: signedIn(),
});
const first = await inventoryOf(app);
const second = await inventoryOf(app);
// Discovery order, which is sorted; the same on every request.
expect(first.modules.map((module) => module.id)).toEqual([
"announcements",
"calendar",
"signage",
]);
expect(second.modules.map((module) => module.id)).toEqual(
first.modules.map((module) => module.id),
);
});
it("leaks no filesystem path, package root, or other manifest internal", async () => {
const {
app,
modules: discovered,
modulesDirectory,
} = await install({
fixtures: [{ id: "example", permissions: [], withUi: true }],
principal: signedIn(),
});
const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string;
const { body, modules } = await inventoryOf(app);
expect(body).not.toContain(modulesDirectory);
expect(body).not.toContain(artifactPath);
expect(body).not.toContain(tmpdir());
expect(body).not.toContain("dist");
expect(body).not.toContain("@test/module-example");
expect(body).not.toContain("ekklesia.module.json");
// The exact key set, so a future field cannot be added without a deliberate decision here.
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
"api",
"contributions",
"display",
"id",
"permissions",
"realtime",
"ui",
"version",
]);
});
it("carries no navigation or presentation metadata", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
const { body } = await inventoryOf(app);
// ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module
// declaration. The manifest's own `name` and `description` must not leak into it by accident.
for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) {
expect(body, field).not.toContain(`"${field}"`);
}
});
});
describe("module UI inventory — authentication", () => {
it("refuses an anonymous caller", async () => {
const { app } = await install({ fixtures: [{ id: "example", withUi: true }] });
const response = await app.request(MODULE_UI_INVENTORY_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("tells an anonymous caller nothing about what is installed", async () => {
const { app } = await install({
fixtures: [
{ id: "example", withUi: true },
{ id: "calendar", withUi: true },
],
});
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
expect(body).not.toContain("example");
expect(body).not.toContain("calendar");
});
it("admits any signed-in user without needing an explicit grant", async () => {
// `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell
// that rendered nothing until an operator granted every user a permission would be unusable.
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
grants: [],
principal: signedIn(),
platformOwner: false,
});
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
});
it("is never cached and varies on the caller's identity", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
const response = await app.request(MODULE_UI_INVENTORY_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("cache-control")).toBe("no-store");
const vary = response.headers.get("vary")?.toLowerCase() ?? "";
expect(vary).toContain("origin");
expect(vary).toContain("cookie");
});
});
describe("module UI inventory — permission hints", () => {
const readPermission = { baseline: "restricted" as const, id: "example.records.read" };
const writePermission = { baseline: "restricted" as const, id: "example.records.write" };
it("returns only the permissions the server actually granted this principal", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: ["example.records.read"],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.read"]);
});
it("returns none of them for a signed-in user holding no grant", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: [],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
// Never a default of true, and never the module's full declared surface.
expect(modules[0]?.permissions).toEqual([]);
});
it("honours a baseline that needs no grant at all", async () => {
const { app } = await install({
fixtures: [
{
id: "example",
permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission],
withUi: true,
},
],
grants: [],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.list"]);
});
it("honours the platform-owner bypass, exactly like a real request would", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: [],
platformOwner: true,
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]);
});
it("scopes hints to each module's own declared permissions", async () => {
const { app } = await install({
fixtures: [
{ id: "example", permissions: [readPermission], withUi: true },
{
id: "calendar",
permissions: [{ baseline: "restricted", id: "calendar.events.read" }],
withUi: true,
},
],
grants: ["example.records.read", "calendar.events.read"],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const byId = new Map(modules.map((module) => [module.id, module.permissions]));
// Neither module learns anything about the other's authorization state, and neither is told
// about Core's own permissions.
expect(byId.get("example")).toEqual(["example.records.read"]);
expect(byId.get("calendar")).toEqual(["calendar.events.read"]);
expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage");
expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read");
});
it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission], withUi: true }],
grantsUnavailable: true,
principal: signedIn(),
});
const response = await app.request(MODULE_UI_INVENTORY_PATH);
// A hint that silently read "denied" during an outage would blank the UI and look like a
// permissions problem.
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
});
describe("module UI inventory — it is not a module management API", () => {
it("routes no method other than GET", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
const response = await app.request(MODULE_UI_INVENTORY_PATH, { method });
expect(response.status, method).toBe(404);
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
}
});
it("still refuses to serve an artifact under the collection path", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) {
const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`);
expect(response.status, suffix).toBe(404);
}
});
});
/**
* The inventory is how a consuming module learns what was contributed to its own capabilities
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
*/
describe("module UI inventory — contributions", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
permissions: [],
withUi: true,
};
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
],
id: "announcements",
permissions: [],
withUi: true,
});
it("gives the providing module the contribution and its server-produced URL", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
expect(consumer?.contributions).toEqual([
{
capability: "signage.slide-renderer",
id: "announcements.signage-slide",
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
version: "1.0.0",
},
]);
});
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
});
it("withholds a contribution whose provider is outside its declared range", async () => {
const { app } = await install({
fixtures: [signage, announcements("^2.0.0")],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
});
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
const { app, modulesDirectory } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { body, modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
expect(body).not.toContain(modulesDirectory);
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
"capability",
"id",
"url",
"version",
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import type {
DashboardConfiguration,
DashboardGroupRecord,
DashboardPlacementRecord,
DashboardStore,
SetDashboardPlacementInput,
UpsertDashboardGroupInput,
} from "@ekklesia/core/dashboard";
import { DashboardStoreError } from "@ekklesia/core/dashboard";
/**
* An in-memory `DashboardStore` for tests that exercise **routing and resolution**, not persistence.
*
* It reproduces only the two behaviours those tests depend on — a placement may not name a group
* that does not exist, and deleting a group ungroups its modules (the schema's
* `on delete set null`) — and deliberately reproduces nothing else. Everything the real store owns
* that this double would only be guessing at (identifier grammar, label trimming and bounds,
* ordering bounds, transactional behaviour, and the actual `on delete set null` constraint) is
* proven against real PostgreSQL in `core/tests/dashboard-store.integration.ts` instead, so this
* double can never quietly stand in for a persistence guarantee nobody checked.
*/
export function createInMemoryDashboardStore(
seed: Partial<DashboardConfiguration> = {},
): DashboardStore {
const groups = new Map<string, DashboardGroupRecord>(
(seed.groups ?? []).map((group) => [group.id, { ...group }] as const),
);
const placements = new Map<string, DashboardPlacementRecord>(
(seed.placements ?? []).map((placement) => [placement.moduleId, { ...placement }] as const),
);
const sorted = <Record extends { readonly sortOrder: number }>(
records: readonly Record[],
key: (record: Record) => string,
): readonly Record[] =>
Object.freeze(
[...records].sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
(key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0),
),
);
return Object.freeze({
async deleteGroup(groupId: string): Promise<void> {
if (!groups.delete(groupId)) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
for (const [moduleId, placement] of placements) {
if (placement.groupId === groupId) {
placements.set(moduleId, { ...placement, groupId: null });
}
}
},
async readConfiguration(): Promise<DashboardConfiguration> {
return Object.freeze({
groups: sorted([...groups.values()], (group) => group.id),
placements: sorted([...placements.values()], (placement) => placement.moduleId),
});
},
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
if (input.groupId !== null && !groups.has(input.groupId)) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
const record: DashboardPlacementRecord = Object.freeze({
groupId: input.groupId,
moduleId: input.moduleId,
sortOrder: input.sortOrder,
});
placements.set(input.moduleId, record);
return record;
},
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
const record: DashboardGroupRecord = Object.freeze({
id: input.id,
label: input.label.trim(),
sortOrder: input.sortOrder,
});
groups.set(input.id, record);
return record;
},
});
}
@@ -0,0 +1,76 @@
import type { ModuleEnablementRecord } from "@ekklesia/contracts";
import type {
DiscoveredModule,
ModuleLifecycleConfiguration,
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
SetModuleEnabledInput,
} from "@ekklesia/core/modules";
import { ModuleLifecycleStoreError, resolveModuleEligibility } from "@ekklesia/core/modules";
/**
* An in-memory `ModuleLifecycleStore` for tests that exercise **routing and resolution**, not
* persistence.
*
* It reproduces only what those tests depend on — records survive a write and read back in
* module-id order, and an invalid identifier is rejected — and deliberately reproduces nothing
* else. The real table's constraints, the upsert's conflict target, and the fact that a record for
* an undeployed module is retained are proven against real PostgreSQL in
* `core/tests/module-lifecycle-store.integration.ts`, so this double can never quietly stand in for
* a persistence guarantee nobody checked.
*/
export function createInMemoryModuleLifecycleStore(
seed: readonly ModuleEnablementRecord[] = [],
): ModuleLifecycleStore {
const records = new Map<string, boolean>(
seed.map((record) => [record.moduleId, record.enabled] as const),
);
return Object.freeze({
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
return Object.freeze({
modules: Object.freeze(
[...records.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })),
),
});
},
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) {
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
}
records.set(input.moduleId, input.enabled);
return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId });
},
});
}
/**
* Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered
* inventory and the stored configuration, then record what would have started — rather than
* hand-writing one, so a test can never assert against a snapshot the real composition would never
* produce.
*/
export function moduleLifecycleSnapshotFor(
discoveredModules: readonly DiscoveredModule[],
configuration: ModuleLifecycleConfiguration = { modules: [] },
): ModuleLifecycleSnapshot {
const resolution = resolveModuleEligibility(discoveredModules, configuration);
return Object.freeze({
discoveredModules,
eligibility: resolution.entries,
startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)),
});
}
/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit
* "no modules" statement the required option asks for, never an implicit default. */
export function emptyModuleLifecycle(): {
readonly snapshot: ModuleLifecycleSnapshot;
readonly store: ModuleLifecycleStore;
} {
return {
snapshot: moduleLifecycleSnapshotFor([]),
store: createInMemoryModuleLifecycleStore(),
};
}
@@ -0,0 +1,208 @@
import {
type ModuleStorage,
type ModuleStorageBody,
ModuleStorageError,
type ModuleStorageListing,
type ModuleStorageListOptions,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStoragePutOptions,
type ModuleStorageReadUrl,
} from "@ekklesia/contracts";
import type { DiscoveredModule, ModuleStorageLookup } from "@ekklesia/core/modules";
import { createModuleStorageLookup, type StorageProvider } from "@ekklesia/core/storage";
/** Builds and drains Web streams without depending on DOM-only helper types, which this package's
* TypeScript configuration deliberately does not load. */
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
total += value.byteLength;
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return merged;
}
interface StoredObject {
readonly bytes: Uint8Array;
readonly contentType?: string;
readonly lastModified: Date;
}
/**
* An in-memory `StorageProvider` for tests that exercise **wiring, scoping, and routing** rather
* than the S3 protocol.
*
* It implements the same interface the real S3 provider does, so everything above the provider seam
* — binding resolution, the scoped facade, lifecycle eligibility, route wiring — is exercised
* unchanged. What it deliberately does *not* reproduce is the protocol itself: real SeaweedFS
* semantics (non-idempotent `CreateBucket`, `NoSuchKey` vs `NoSuchBucket`, ETag quoting, streaming
* bodies) are proven against a live server in `core/tests/module-storage.integration.ts`, so this
* double can never quietly stand in for a provider guarantee nobody checked.
*
* It keys everything by **physical** bucket, exactly as the real provider does, which is what lets a
* test assert that two modules declaring the same logical name really did land in different
* namespaces.
*/
export function createInMemoryStorageProvider(): StorageProvider & {
readonly buckets: ReadonlyMap<string, Map<string, StoredObject>>;
ensureBucketCalls: number;
} {
const buckets = new Map<string, Map<string, StoredObject>>();
const state = { ensureBucketCalls: 0 };
const objectsIn = (physicalBucket: string): Map<string, StoredObject> => {
const bucket = buckets.get(physicalBucket);
if (!bucket) {
throw new ModuleStorageError("STORAGE_BUCKET_UNAVAILABLE");
}
return bucket;
};
const metadataFor = (key: string, stored: StoredObject): ModuleStorageObjectMetadata =>
Object.freeze({
contentLength: stored.bytes.byteLength,
key,
lastModified: stored.lastModified,
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
});
const provider: StorageProvider = {
async close(): Promise<void> {
// Nothing to release.
},
async createReadUrl(
physicalBucket: string,
key: string,
urlOptions: { readonly expiresInSeconds: number; readonly downloadFilename?: string },
): Promise<ModuleStorageReadUrl> {
// Keyed by physical bucket like everything else here, so a route test can prove the URL a
// handler received belongs to that module's namespace and not another's.
return Object.freeze({
expiresAt: new Date(Date.now() + urlOptions.expiresInSeconds * 1000),
url: `https://delivery.test/${physicalBucket}/${encodeURIComponent(key)}?token=fake`,
});
},
async delete(physicalBucket: string, key: string): Promise<void> {
objectsIn(physicalBucket).delete(key);
},
async ensureBucket(physicalBucket: string): Promise<void> {
state.ensureBucketCalls += 1;
if (!buckets.has(physicalBucket)) {
buckets.set(physicalBucket, new Map());
}
},
async get(physicalBucket: string, key: string): Promise<ModuleStorageObject> {
const stored = objectsIn(physicalBucket).get(key);
if (!stored) {
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
}
return Object.freeze({
...metadataFor(key, stored),
body: streamOf(stored.bytes),
});
},
async head(physicalBucket: string, key: string): Promise<ModuleStorageObjectMetadata> {
const stored = objectsIn(physicalBucket).get(key);
if (!stored) {
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
}
return metadataFor(key, stored);
},
async list(
physicalBucket: string,
options: ModuleStorageListOptions,
): Promise<ModuleStorageListing> {
const entries = [...objectsIn(physicalBucket).entries()]
.filter(([key]) => options.prefix === undefined || key.startsWith(options.prefix))
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
return Object.freeze({
objects: Object.freeze(entries.map(([key, stored]) => metadataFor(key, stored))),
});
},
async probe(): Promise<void> {
// Always reachable.
},
async put(
physicalBucket: string,
key: string,
body: ModuleStorageBody,
options: ModuleStoragePutOptions,
): Promise<ModuleStorageObjectMetadata> {
const bytes =
typeof body === "string"
? new TextEncoder().encode(body)
: body instanceof Uint8Array
? body
: await collect(body as ReadableStream<Uint8Array>);
const stored: StoredObject = {
bytes,
lastModified: new Date(0),
...(options.contentType === undefined ? {} : { contentType: options.contentType }),
};
objectsIn(physicalBucket).set(key, stored);
return metadataFor(key, stored);
},
};
return Object.assign(provider, {
buckets: buckets as ReadonlyMap<string, Map<string, StoredObject>>,
get ensureBucketCalls(): number {
return state.ensureBucketCalls;
},
});
}
export interface ModuleStorageOption {
readonly snapshots: ModuleStorageLookup;
readonly providerConfigured: boolean;
}
/**
* Builds the `createApiApp` storage option the same way `startApi` does — resolve every module's
* declared buckets against one provider and one prefix — rather than hand-writing a map, so a test
* can never assert against a shape the real composition would not produce.
*/
export function moduleStorageOptionFor(
discoveredModules: readonly DiscoveredModule[],
provider?: StorageProvider,
): ModuleStorageOption {
return {
providerConfigured: provider !== undefined,
snapshots: createModuleStorageLookup(
provider,
"ekklesia",
discoveredModules.map((module) => ({
logicalBucketIds: module.manifest.storage.buckets.map((bucket) => bucket.id),
moduleId: module.manifest.id,
})),
),
};
}
/** The storage option for a `createApiApp` caller that exercises no storage at all — the explicit
* "this installation has no object storage" statement the required option asks for. */
export function emptyModuleStorage(): ModuleStorageOption {
return { providerConfigured: false, snapshots: new Map<string, ModuleStorage>() };
}
+8 -1
View File
@@ -2,7 +2,14 @@ import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
dialect: "postgresql", dialect: "postgresql",
schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"], schema: [
"./src/identity/schema.ts",
"./src/authorization/schema.ts",
"./src/dashboard/schema.ts",
"./src/modules/lifecycle-schema.ts",
"./src/modules/configuration-schema.ts",
"./src/devices/schema.ts",
],
out: "./drizzle", out: "./drizzle",
migrations: { migrations: {
prefix: "index", prefix: "index",
@@ -0,0 +1,22 @@
CREATE TABLE "ekklesia_core_dashboard_group" (
"id" text PRIMARY KEY NOT NULL,
"label" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_dashboard_group_id_check" CHECK (length("ekklesia_core_dashboard_group"."id") > 0),
CONSTRAINT "ekklesia_core_dashboard_group_label_check" CHECK (length(btrim("ekklesia_core_dashboard_group"."label")) > 0)
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_dashboard_placement" (
"module_id" text PRIMARY KEY NOT NULL,
"group_id" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_dashboard_placement_module_check" CHECK (length("ekklesia_core_dashboard_placement"."module_id") > 0)
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_dashboard_placement" ADD CONSTRAINT "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."ekklesia_core_dashboard_group"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ekklesia_core_dashboard_group_order_idx" ON "ekklesia_core_dashboard_group" USING btree ("sort_order","id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_dashboard_placement_group_idx" ON "ekklesia_core_dashboard_placement" USING btree ("group_id","sort_order");
@@ -0,0 +1,7 @@
CREATE TABLE "ekklesia_core_module_state" (
"module_id" text PRIMARY KEY NOT NULL,
"enabled" boolean NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_module_state_module_check" CHECK (length("ekklesia_core_module_state"."module_id") > 0)
);
@@ -0,0 +1,13 @@
CREATE TABLE "ekklesia_core_module_configuration" (
"module_id" text NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_module_configuration_module_id_key_pk" PRIMARY KEY("module_id","key"),
CONSTRAINT "ekklesia_core_module_configuration_module_check" CHECK (length("ekklesia_core_module_configuration"."module_id") > 0),
CONSTRAINT "ekklesia_core_module_configuration_key_check" CHECK (length("ekklesia_core_module_configuration"."key") > 0),
CONSTRAINT "ekklesia_core_module_configuration_value_check" CHECK (length("ekklesia_core_module_configuration"."value") <= 2048)
);
--> statement-breakpoint
CREATE INDEX "ekklesia_core_module_configuration_module_idx" ON "ekklesia_core_module_configuration" USING btree ("module_id");
+53
View File
@@ -0,0 +1,53 @@
CREATE TABLE "ekklesia_core_device" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"credential_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_by_user_id" text,
"last_seen_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"revoked_by_user_id" text,
CONSTRAINT "ekklesia_core_device_name_check" CHECK (length("ekklesia_core_device"."name") between 1 and 128),
CONSTRAINT "ekklesia_core_device_credential_hash_check" CHECK (length("ekklesia_core_device"."credential_hash") = 64)
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_device_pairing_request" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" text NOT NULL,
"claim_secret_hash" text NOT NULL,
"module_id" text NOT NULL,
"permission_id" text NOT NULL,
"state" text DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"device_name" text,
"approved_at" timestamp with time zone,
"approved_by_user_id" text,
"consumed_at" timestamp with time zone,
"device_id" uuid,
CONSTRAINT "ekklesia_core_device_pairing_code_check" CHECK ("ekklesia_core_device_pairing_request"."code" ~ '^[0-9]{6}$'),
CONSTRAINT "ekklesia_core_device_pairing_claim_hash_check" CHECK (length("ekklesia_core_device_pairing_request"."claim_secret_hash") = 64),
CONSTRAINT "ekklesia_core_device_pairing_state_check" CHECK ("ekklesia_core_device_pairing_request"."state" in ('pending', 'approved', 'consumed'))
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_device_permission" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"permission_id" text NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
"granted_by_user_id" text,
"revoked_at" timestamp with time zone,
CONSTRAINT "ekklesia_core_device_permission_id_check" CHECK (length("ekklesia_core_device_permission"."permission_id") between 1 and 128)
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device" ADD CONSTRAINT "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("revoked_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("approved_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_pairing_request" ADD CONSTRAINT "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."ekklesia_core_device"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_device_permission" ADD CONSTRAINT "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("granted_by_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "ekklesia_core_device_credential_hash_idx" ON "ekklesia_core_device" USING btree ("credential_hash");--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_revoked_idx" ON "ekklesia_core_device" USING btree ("revoked_at");--> statement-breakpoint
CREATE UNIQUE INDEX "ekklesia_core_device_pairing_code_idx" ON "ekklesia_core_device_pairing_request" USING btree ("code") WHERE "ekklesia_core_device_pairing_request"."state" <> 'consumed';--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_pairing_claim_idx" ON "ekklesia_core_device_pairing_request" USING btree ("claim_secret_hash");--> statement-breakpoint
CREATE INDEX "ekklesia_core_device_permission_lookup_idx" ON "ekklesia_core_device_permission" USING btree ("device_id","permission_id");
@@ -0,0 +1,20 @@
-- Email verification becomes a precondition for holding a session (ADR 0038). Any platform owner
-- that already exists was created by the trusted operator-run bootstrap command before that command
-- marked addresses verified, so without this they would be locked out of their own installation by
-- an upgrade.
--
-- Deliberately narrow. It marks exactly the identities the authorization tables already record as
-- currently-granted platform owners: an authority the deployment established through a command that
-- required database credentials, which is the same authority the bootstrap verification exception
-- rests on. Every other account is left untouched and must verify by email like any other.
--
-- Data-only and idempotent. It adds no column, drops nothing, and re-running it changes no row that
-- is not already false.
UPDATE "ekklesia_core_user"
SET "email_verified" = true
WHERE "email_verified" = false
AND "id" IN (
SELECT "user_id"
FROM "ekklesia_core_platform_owner"
WHERE "revoked_at" IS NULL
);
+870
View File
@@ -0,0 +1,870 @@
{
"id": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
"prevId": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_group": {
"name": "ekklesia_core_dashboard_group",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_group_order_idx": {
"name": "ekklesia_core_dashboard_group_order_idx",
"columns": [
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_group_id_check": {
"name": "ekklesia_core_dashboard_group_id_check",
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
},
"ekklesia_core_dashboard_group_label_check": {
"name": "ekklesia_core_dashboard_group_label_check",
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_placement": {
"name": "ekklesia_core_dashboard_placement",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_placement_group_idx": {
"name": "ekklesia_core_dashboard_placement_group_idx",
"columns": [
{
"expression": "group_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
"tableFrom": "ekklesia_core_dashboard_placement",
"tableTo": "ekklesia_core_dashboard_group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_placement_module_check": {
"name": "ekklesia_core_dashboard_placement_module_check",
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+914
View File
@@ -0,0 +1,914 @@
{
"id": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
"prevId": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_group": {
"name": "ekklesia_core_dashboard_group",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_group_order_idx": {
"name": "ekklesia_core_dashboard_group_order_idx",
"columns": [
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_group_id_check": {
"name": "ekklesia_core_dashboard_group_id_check",
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
},
"ekklesia_core_dashboard_group_label_check": {
"name": "ekklesia_core_dashboard_group_label_check",
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_placement": {
"name": "ekklesia_core_dashboard_placement",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_placement_group_idx": {
"name": "ekklesia_core_dashboard_placement_group_idx",
"columns": [
{
"expression": "group_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
"tableFrom": "ekklesia_core_dashboard_placement",
"tableTo": "ekklesia_core_dashboard_group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_placement_module_check": {
"name": "ekklesia_core_dashboard_placement_module_check",
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_module_state": {
"name": "ekklesia_core_module_state",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_module_state_module_check": {
"name": "ekklesia_core_module_state_module_check",
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+993
View File
@@ -0,0 +1,993 @@
{
"id": "d41e490c-dbe1-4d73-9e11-6e79baa91908",
"prevId": "b153e7a4-3100-4610-b296-a1a0b3cbfff6",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_group": {
"name": "ekklesia_core_dashboard_group",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_group_order_idx": {
"name": "ekklesia_core_dashboard_group_order_idx",
"columns": [
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_group_id_check": {
"name": "ekklesia_core_dashboard_group_id_check",
"value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0"
},
"ekklesia_core_dashboard_group_label_check": {
"name": "ekklesia_core_dashboard_group_label_check",
"value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_dashboard_placement": {
"name": "ekklesia_core_dashboard_placement",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"group_id": {
"name": "group_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_dashboard_placement_group_idx": {
"name": "ekklesia_core_dashboard_placement_group_idx",
"columns": [
{
"expression": "group_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": {
"name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk",
"tableFrom": "ekklesia_core_dashboard_placement",
"tableTo": "ekklesia_core_dashboard_group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_dashboard_placement_module_check": {
"name": "ekklesia_core_dashboard_placement_module_check",
"value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_module_state": {
"name": "ekklesia_core_module_state",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_module_state_module_check": {
"name": "ekklesia_core_module_state_module_check",
"value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_module_configuration": {
"name": "ekklesia_core_module_configuration",
"schema": "",
"columns": {
"module_id": {
"name": "module_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_module_configuration_module_idx": {
"name": "ekklesia_core_module_configuration_module_idx",
"columns": [
{
"expression": "module_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"ekklesia_core_module_configuration_module_id_key_pk": {
"name": "ekklesia_core_module_configuration_module_id_key_pk",
"columns": ["module_id", "key"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_module_configuration_module_check": {
"name": "ekklesia_core_module_configuration_module_check",
"value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0"
},
"ekklesia_core_module_configuration_key_check": {
"name": "ekklesia_core_module_configuration_key_check",
"value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0"
},
"ekklesia_core_module_configuration_value_check": {
"name": "ekklesia_core_module_configuration_value_check",
"value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30
View File
@@ -19,6 +19,36 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0003_dashboard_navigation_tables",
"destructive": {
"mode": "none"
}
},
{
"tag": "0004_module_lifecycle_state",
"destructive": {
"mode": "none"
}
},
{
"tag": "0005_module_configuration",
"destructive": {
"mode": "none"
}
},
{
"tag": "0006_device_identity",
"destructive": {
"mode": "none"
}
},
{
"tag": "0007_verified_bootstrap_owners",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+35
View File
@@ -22,6 +22,41 @@
"when": 1785104670124, "when": 1785104670124,
"tag": "0002_authorization_integrity", "tag": "0002_authorization_integrity",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1787408382271,
"tag": "0003_dashboard_navigation_tables",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1787423121678,
"tag": "0004_module_lifecycle_state",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1787451496425,
"tag": "0005_module_configuration",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787543773057,
"tag": "0006_device_identity",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787543773058,
"tag": "0007_verified_bootstrap_owners",
"breakpoints": true
} }
] ]
} }
+26
View File
@@ -15,6 +15,14 @@
"types": "./dist/modules/index.d.ts", "types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js" "import": "./dist/modules/index.js"
}, },
"./storage": {
"types": "./dist/storage/index.d.ts",
"import": "./dist/storage/index.js"
},
"./dashboard": {
"types": "./dist/dashboard/index.d.ts",
"import": "./dist/dashboard/index.js"
},
"./migrations": { "./migrations": {
"types": "./dist/migrations/index.d.ts", "types": "./dist/migrations/index.d.ts",
"import": "./dist/migrations/index.js" "import": "./dist/migrations/index.js"
@@ -34,6 +42,18 @@
"./database": { "./database": {
"types": "./dist/database/index.d.ts", "types": "./dist/database/index.d.ts",
"import": "./dist/database/index.js" "import": "./dist/database/index.js"
},
"./devices": {
"types": "./dist/devices/index.d.ts",
"import": "./dist/devices/index.js"
},
"./mail": {
"types": "./dist/mail/index.d.ts",
"import": "./dist/mail/index.js"
},
"./host": {
"types": "./dist/host/index.d.ts",
"import": "./dist/host/index.js"
} }
}, },
"scripts": { "scripts": {
@@ -45,17 +65,23 @@
"typecheck": "tsc --noEmit --project tsconfig.json" "typecheck": "tsc --noEmit --project tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.1116.0",
"@aws-sdk/s3-request-presigner": "3.1116.0",
"@ekklesia/contracts": "workspace:*", "@ekklesia/contracts": "workspace:*",
"better-auth": "1.6.23", "better-auth": "1.6.23",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"nodemailer": "9.0.5",
"pg": "8.22.0" "pg": "8.22.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"auth": "1.6.23", "auth": "1.6.23",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.10" "vitest": "4.1.10"
}, },
+41 -7
View File
@@ -5,11 +5,16 @@ import {
type AuthorizationScope, type AuthorizationScope,
type EkklesiaPrincipal, type EkklesiaPrincipal,
isAuthenticatedPrincipal, isAuthenticatedPrincipal,
isDevicePrincipal,
permissionBaselineSchema, permissionBaselineSchema,
safeParseAuthorizationScope, safeParseAuthorizationScope,
safeParsePermissionId, safeParsePermissionId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js"; import type {
AuthorizationDevicePort,
AuthorizationGrantPort,
AuthorizationPlatformOwnerPort,
} from "./ports.js";
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED"; export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
@@ -54,6 +59,10 @@ export interface AuthorizationEvaluator {
export interface CreateAuthorizationEvaluatorOptions { export interface CreateAuthorizationEvaluatorOptions {
readonly grants: AuthorizationGrantPort; readonly grants: AuthorizationGrantPort;
readonly platformOwners: AuthorizationPlatformOwnerPort; readonly platformOwners: AuthorizationPlatformOwnerPort;
/** Device authority. Optional so every existing caller composes unchanged; when it is absent a
* device principal simply has no permissions and is denied, which is the correct answer for a
* deployment that has no device support wired at all. */
readonly devices?: AuthorizationDevicePort;
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the /** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
* real wall clock; production composition must leave this unset. */ * real wall clock; production composition must leave this unset. */
readonly now?: () => Date; readonly now?: () => Date;
@@ -100,17 +109,14 @@ export function createAuthorizationEvaluator(
} }
if (parsedBaseline.data === "authenticated") { if (parsedBaseline.data === "authenticated") {
// A device deliberately does not satisfy this. The baseline means "a person is signed in",
// and every route that settles for it — the module UI inventory, anything a human is
// expected to be looking at — would otherwise open to an unattended screen (ADR 0036).
return isAuthenticatedPrincipal(request.principal) return isAuthenticatedPrincipal(request.principal)
? AUTHORIZATION_ALLOWED ? AUTHORIZATION_ALLOWED
: AUTHORIZATION_DENIED; : AUTHORIZATION_DENIED;
} }
// parsedBaseline.data === "restricted": requires an authenticated principal AND
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
if (!isAuthenticatedPrincipal(request.principal)) {
return AUTHORIZATION_DENIED;
}
let scope: AuthorizationScope | undefined; let scope: AuthorizationScope | undefined;
if (request.scope !== undefined) { if (request.scope !== undefined) {
const parsedScope = safeParseAuthorizationScope(request.scope); const parsedScope = safeParseAuthorizationScope(request.scope);
@@ -120,6 +126,34 @@ export function createAuthorizationEvaluator(
scope = parsedScope.data; scope = parsedScope.data;
} }
// A device satisfies `restricted` only by holding that exact permission, and by no other
// route: there is no owner check here, no baseline that stands in for a grant, and no branch
// that returns `allowed` before this lookup. "Paired" is not authority — a device that was
// paired for one display has nothing else.
if (isDevicePrincipal(request.principal)) {
if (!options.devices || scope !== undefined) {
// A scoped check has no device meaning yet; denying is the honest answer rather than
// silently treating it as unscoped.
return AUTHORIZATION_DENIED;
}
try {
return (await options.devices.hasActivePermission(
request.principal.deviceId,
parsedPermission.data,
))
? AUTHORIZATION_ALLOWED
: AUTHORIZATION_DENIED;
} catch {
throw new AuthorizationEvaluationError();
}
}
// parsedBaseline.data === "restricted": requires an authenticated principal AND
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
if (!isAuthenticatedPrincipal(request.principal)) {
return AUTHORIZATION_DENIED;
}
const userId = request.principal.userId; const userId = request.principal.userId;
let isOwner: boolean; let isOwner: boolean;
let hasGrant: boolean; let hasGrant: boolean;
+1
View File
@@ -1,6 +1,7 @@
export * from "./bootstrap.js"; export * from "./bootstrap.js";
export * from "./database.js"; export * from "./database.js";
export * from "./evaluator.js"; export * from "./evaluator.js";
export * from "./initial-owner.js";
export * from "./permissions.js"; export * from "./permissions.js";
export * from "./ports.js"; export * from "./ports.js";
export * from "./store.js"; export * from "./store.js";
+323
View File
@@ -0,0 +1,323 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
import { and, eq, sql } from "drizzle-orm";
import type { BootstrapIdentityCreator } from "../identity/bootstrap-identity.js";
import { BootstrapIdentityError } from "../identity/bootstrap-identity.js";
import type { IdentityDrizzleDatabase } from "../identity/database.js";
import { ekklesiaCoreAccount, ekklesiaCoreUser } from "../identity/schema.js";
import { bootstrapPlatformOwner, PlatformOwnerBootstrapError } from "./bootstrap.js";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import { ekklesiaCorePlatformOwnerBootstrap } from "./schema.js";
export type InitialPlatformOwnerErrorCode =
| "INITIAL_OWNER_ALREADY_COMPLETED"
| "INITIAL_OWNER_EMAIL_INVALID"
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
| "INITIAL_OWNER_SCHEMA_UNAVAILABLE";
const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, string>> = Object.freeze(
{
INITIAL_OWNER_ALREADY_COMPLETED:
"This installation already has a platform owner. Initial-owner bootstrap runs once and cannot run again.",
INITIAL_OWNER_EMAIL_INVALID: "The email address is not a valid address.",
INITIAL_OWNER_IDENTITY_NOT_CREATED:
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
INITIAL_OWNER_IDENTITY_UNUSABLE:
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
INITIAL_OWNER_PASSWORD_TOO_SHORT: "The password is shorter than the identity system allows.",
INITIAL_OWNER_SCHEMA_UNAVAILABLE:
"The database is unreachable or has not been migrated. Apply migrations before creating the initial owner.",
},
);
/**
* Never carries the password, a hash, a connection string, SQL, a driver error, or a cause. The
* messages are written to be read by an operator at a terminal, so each one states what happened
* *and* what is still true about the installation — in particular whether bootstrap remains
* available, which is the only fact that determines what they should do next.
*/
export class InitialPlatformOwnerError extends Error {
readonly code: InitialPlatformOwnerErrorCode;
constructor(code: InitialPlatformOwnerErrorCode) {
super(initialOwnerMessages[code]);
this.name = "InitialPlatformOwnerError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
// A CLI-level advisory key distinct from `bootstrap.ts`'s: taking the same one would deadlock this
// operation against the grant it performs. This serializes whole invocations; that one serializes
// the grant transaction itself, and both remain in force.
const initialOwnerLockNamespace = 1_500_000_003;
const initialOwnerLockKey = 1_500_000_004;
export interface CreateInitialPlatformOwnerDependencies {
readonly authorizationDb: AuthorizationDrizzleDatabase;
readonly identityDb: IdentityDrizzleDatabase;
readonly createIdentity: BootstrapIdentityCreator;
/** Injected so a partial failure — identity created, ownership not granted — can be exercised.
* Production composition leaves it unset and the real single-use bootstrap is used. */
readonly grantOwnership?: (userId: EkklesiaUserId) => Promise<void>;
}
export interface CreateInitialPlatformOwnerInput {
readonly email: string;
readonly password: string;
/** The display name the identity model requires. */
readonly name: string;
}
export interface InitialPlatformOwnerResult {
readonly email: string;
readonly userId: EkklesiaUserId;
/** `created` when this call made the identity; `adopted` when one already existed for that
* address and only ownership was granted. Reported so an operator is never told something was
* created when it was not. */
readonly identity: "adopted" | "created";
}
interface ExistingIdentity {
readonly userId: string;
readonly hasCredential: boolean;
/** As the identity system actually stored it, which is not necessarily as the operator typed it. */
readonly storedEmail: string;
}
/**
* Finds an identity by email, case-insensitively.
*
* Deliberately not an exact match on the caller's spelling: Better Auth lowercases the address it
* stores (verified against the pinned release — `[email protected]` persists as
* `[email protected]`), so an exact comparison would fail to find the very account this operation
* had just created and would report it missing.
*/
async function findIdentityByEmail(
db: IdentityDrizzleDatabase,
email: string,
): Promise<ExistingIdentity | undefined> {
const rows = await db
.select({ email: ekklesiaCoreUser.email, id: ekklesiaCoreUser.id })
.from(ekklesiaCoreUser)
.where(sql`lower(${ekklesiaCoreUser.email}) = ${email.trim().toLowerCase()}`)
.limit(1);
const found = rows[0];
if (!found) {
return undefined;
}
// A row in `user` alone is not an account anyone can sign in as — the credential lives in
// `account`. The distinction matters: granting ownership to a passwordless row would consume the
// one-time bootstrap on an identity nobody can authenticate as, leaving the installation with an
// owner that cannot log in and no second chance.
const credentials = await db
.select({ id: ekklesiaCoreAccount.id })
.from(ekklesiaCoreAccount)
.where(
and(
eq(ekklesiaCoreAccount.userId, found.id),
eq(ekklesiaCoreAccount.providerId, "credential"),
),
)
.limit(1);
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
}
/**
* Marks one identity's address verified.
*
* A direct column update rather than an identity-system call, because the pinned release exposes no
* supported way to mark an address verified other than consuming a token it emailed — which is the
* one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the
* exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from
* one that verified normally; nothing here invents a parallel notion of verification.
*/
async function markIdentityEmailVerified(
db: IdentityDrizzleDatabase,
userId: string,
): Promise<void> {
await db
.update(ekklesiaCoreUser)
.set({ emailVerified: true })
.where(eq(ekklesiaCoreUser.id, userId));
}
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
if (error instanceof BootstrapIdentityError) {
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_SHORT");
}
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG") {
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_LONG");
}
if (error.code === "BOOTSTRAP_IDENTITY_EMAIL_INVALID") {
return new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
}
}
return new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
}
/**
* Creates the first platform owner of a fresh installation: one identity, then the single-use
* ownership grant.
*
* ## Why the identity is verified rather than trusted
*
* Better Auth's sign-up returns a **fabricated success** when the address is already registered —
* a user object carrying an id that was never persisted, with the database left unchanged (verified
* against the pinned release). On a public endpoint that is correct: it refuses to tell an
* anonymous caller which addresses exist. Here it is a trap, because a caller that believed the
* response would report an owner it never created and then grant ownership to an id that does not
* exist. So the identity is always read back from the database, and that read — not the sign-up
* response — is what the grant uses.
*
* ## Why an existing identity is adopted rather than refused or overwritten
*
* If the address already has a usable account and bootstrap is still open, ownership is granted to
* it and **its password is left completely alone**. This is precisely the recovery path for the one
* partial failure this operation can have: sign-up commits through Better Auth's own connection and
* the grant runs in a separate transaction, so a crash between them leaves an identity with no
* ownership. Refusing would strand a fresh installation with no way forward but manual SQL; a
* password reset would be a silent takeover of an account the operator may not own. Adoption
* changes no credential, and it is reported as `adopted` so nobody is told an account was created
* when it was not.
*
* That is safe here specifically because production has **no** other way to create an identity —
* public signup cannot be enabled at all — so an account present on a fresh installation was put
* there by someone who already had database access.
*
* ## Ordering
*
* The bootstrap sentinel is checked before an identity is created, so a closed bootstrap never
* leaves a stray account behind. That check is an early exit, not the authority: `bootstrapPlatformOwner`
* re-checks it inside its own locked transaction, which is what actually makes the grant single-use.
*/
export async function createInitialPlatformOwner(
dependencies: CreateInitialPlatformOwnerDependencies,
input: CreateInitialPlatformOwnerInput,
): Promise<InitialPlatformOwnerResult> {
const email = input.email.trim();
if (email.length === 0) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
}
// Serializes entire invocations against each other. Without it two operators racing on a fresh
// installation would both pass the sentinel check and both create an identity, and only then
// would one lose the grant — leaving a stray account that can sign in and owns nothing. The grant
// alone was already safe; this makes the whole command safe.
return dependencies.authorizationDb.transaction(async (lockTx) => {
await lockTx.execute(
sql`select pg_advisory_xact_lock(${initialOwnerLockNamespace}, ${initialOwnerLockKey})`,
);
let alreadyConsumed: boolean;
try {
const consumed = await lockTx
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
.from(ekklesiaCorePlatformOwnerBootstrap)
.limit(1);
alreadyConsumed = consumed.length > 0;
} catch {
// An unreachable database and a database without the authorization tables are the same
// situation to an operator running this on a fresh server: the deployment step before this
// one has not happened. Never migrated implicitly — see ADR 0035.
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
}
if (alreadyConsumed) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
}
let existing: ExistingIdentity | undefined;
try {
existing = await findIdentityByEmail(dependencies.identityDb, email);
} catch {
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
}
if (existing && !existing.hasCredential) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_UNUSABLE");
}
// Captured before the read-back below, which cannot distinguish the two: an identity this call
// just created and one it adopted both come back with a credential.
const identity: "adopted" | "created" = existing ? "adopted" : "created";
if (!existing) {
try {
await dependencies.createIdentity({
email,
name: input.name,
password: input.password,
});
} catch (error) {
throw translateIdentityCreationError(error);
}
existing = await findIdentityByEmail(dependencies.identityDb, email).catch(() => undefined);
if (!existing) {
// Reached when sign-up reported success without persisting anything.
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
}
}
const parsedUserId = safeParseEkklesiaUserId(existing.userId);
if (!parsedUserId.success) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
}
/**
* The trusted-bootstrap verification exception (ADR 0038).
*
* Every other human account must verify its address by email before it may hold a session.
* This one cannot: it is created on a freshly provisioned server by an operator who already
* holds the database credentials, at a moment when SMTP may not be configured, may not be
* reachable, or may not yet have a DNS record to send from. Requiring an email round trip here
* would mean a working installation whose administrator can never get in — the platform's
* outbound mail would have to be configured and correct before anyone could sign in to
* configure anything.
*
* The exception is narrow and its authority is the same authority that already makes this
* command safe: possession of the database, not possession of the mailbox. It marks exactly one
* address, exactly once per installation, and it is deliberately performed **before** ownership
* is granted — if it fails, the sentinel and the grant roll back together and re-running the
* command adopts the identity and finishes, rather than leaving an owner who cannot sign in.
*/
try {
await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data);
} catch {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED");
}
try {
const grant =
dependencies.grantOwnership ??
((userId: EkklesiaUserId) =>
bootstrapPlatformOwner(dependencies.authorizationDb, { userId }));
await grant(parsedUserId.data);
} catch (error) {
if (
error instanceof PlatformOwnerBootstrapError &&
error.code === "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
}
// The identity is real and remains usable; the sentinel rolled back with the grant, so
// re-running this command with the same address adopts it and finishes.
throw new InitialPlatformOwnerError("INITIAL_OWNER_OWNERSHIP_GRANT_FAILED");
}
// The stored address, never the operator's spelling: the identity system lowercases what it
// persists, and telling someone to sign in as `[email protected]` when the account is
// `[email protected]` invites them to conclude the command failed.
return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data });
});
}
+111
View File
@@ -83,6 +83,92 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
"core.authorization.grants.manage", "core.authorization.grants.manage",
); );
/**
* Authenticated: any signed-in user of this installation may learn which module UIs the platform is
* currently hosting, because that is exactly what a browser shell has to render before it can offer
* anything at all. It is deliberately not `public` — the anonymous artifact route (ADR 0025) is
* careful never to become an enumeration oracle, and an anonymous inventory would hand over the very
* listing that route withholds. It is deliberately not `restricted` either: a shell that rendered
* nothing until an operator granted every user this permission would make the platform unusable for
* its ordinary users, and the module UIs themselves already gate their own content on their own
* permissions.
*
* Listing a module UI is not permission to use that module. Every module route remains enforced
* against its own declared permission, unchanged.
*/
export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read");
/**
* Restricted: reorganizing the Dashboard is an administrative action, not something every signed-in
* user may do. It is deliberately a **platform** permission and never a module's own — grouping is
* Ekklesia's presentation configuration, so no module may gate, grant, or influence it (ADR 0027).
*
* Holding it confers no module authority whatsoever. Moving a module into a folder changes a
* presentation row; it grants nobody access to that module's data, and every module route stays
* enforced against its own declared permission exactly as before.
*/
export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId(
"core.dashboard.navigation.manage",
);
/**
* Restricted, and it gates **reading as well as changing**. Deciding which installed modules run is
* an administrative operation, and the read is not a milder version of it: the lifecycle view is the
* only place a *disabled* module, a *blocked* module, and the required-dependency edges between them
* are visible at all. `core.modules.ui.read` deliberately exposes the mountable set to every signed-in
* user; this exposes the parts of the installation that are deliberately not running, which is not
* the same audience. A second, weaker read permission over the same resource would be ceremony
* (ADR 0027's reasoning, applied in the other direction).
*
* It is a **platform** permission and never a module's. A module can neither declare it, gate it, nor
* observe it: whether a module runs is a decision its host makes about it, not a decision it takes
* part in (ADR 0028). Holding it confers no authority inside any module — every module route stays
* enforced against that module's own declared permission.
*/
export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId(
"core.modules.lifecycle.manage",
);
/**
* Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize
* genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides
* *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted
* to take modules offline, and the reverse is equally true, so folding them into one permission
* would grant authority nobody asked for in whichever direction it was used (ADR 0029).
*
* It gates reading as well as writing, because the read returns the module's declared schema
* together with every stored value — endpoints, bucket names, and whatever else an operator has
* configured. That is deployment detail, not something every signed-in user needs.
*
* **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never
* persisted, and is never returned by any route; the API reports only whether one is configured.
* Holding this permission therefore confers no ability to read a credential — there is no code path
* that would serve one.
*
* Like every other Core permission it is a **platform** permission a module can neither declare,
* gate, nor observe, and holding it confers no authority inside any module.
*/
export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId(
"core.modules.configuration.manage",
);
/**
* Restricted, and deliberately a **platform** permission rather than a module's.
*
* A device is an identity the platform issues, so deciding that a screen in the foyer may exist —
* approving its pairing, seeing what has been paired, and taking it away again — is authority over
* Ekklesia itself, not over the module whose display happens to be shown. Letting a module declare
* or gate it would mean whoever administers a slideshow could mint credentials that outlive it.
*
* It gates approval, listing, and revocation together. Splitting "see the paired devices" from
* "revoke one" would be ceremony: the same person is trusted with both, and a reader who cannot act
* on what they see is not a role anyone has asked for (ADR 0027's reasoning, applied again).
*
* Holding it confers **no** authority inside any module and no ability to read a device credential
* — none is stored in a recoverable form, so there is no code path that could serve one (ADR 0036).
*/
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
/** /**
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module * Core's own declarations, exported in deeply immutable form so a trusted composer (the module
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
@@ -96,6 +182,31 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
description: "Create, list, and revoke another user's explicit authorization grants.", description: "Create, list, and revoke another user's explicit authorization grants.",
id: CORE_AUTHORIZATION_GRANTS_MANAGE, id: CORE_AUTHORIZATION_GRANTS_MANAGE,
}), }),
Object.freeze({
baseline: "authenticated" as const,
description: "List the installed module UIs a browser host may mount.",
id: CORE_MODULES_UI_READ,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Read and change which installed modules are enabled.",
id: CORE_MODULES_LIFECYCLE_MANAGE,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Read and change an installed module's own configuration values.",
id: CORE_MODULES_CONFIGURATION_MANAGE,
}),
Object.freeze({
baseline: "restricted" as const,
description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE,
}),
]); ]);
/** /**
+20 -1
View File
@@ -1,4 +1,9 @@
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; import type {
AuthorizationScope,
EkklesiaDeviceId,
EkklesiaUserId,
PermissionId,
} from "@ekklesia/contracts";
export interface ActiveGrantQuery { export interface ActiveGrantQuery {
readonly userId: EkklesiaUserId; readonly userId: EkklesiaUserId;
@@ -26,3 +31,17 @@ export interface AuthorizationGrantPort {
export interface AuthorizationPlatformOwnerPort { export interface AuthorizationPlatformOwnerPort {
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>; isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
} }
/**
* The evaluator's only read dependency for **device** authority.
*
* Separate from `AuthorizationGrantPort` because the two answer questions about different kinds of
* subject, not because devices are a special case in the decision: `authorize` consults exactly one
* of them per call, by principal kind, and neither can be reached without going through it.
*
* There is deliberately no device counterpart to `AuthorizationPlatformOwnerPort`. A device is
* never a platform owner, so there is no port that could accidentally say it was.
*/
export interface AuthorizationDevicePort {
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
}
+233
View File
@@ -0,0 +1,233 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
createAuthorizationDrizzleDatabase,
createInitialPlatformOwner,
InitialPlatformOwnerError,
} from "../authorization/index.js";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
} from "../identity/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
/**
* Creates the first platform owner of a fresh installation — one identity, then the single-use
* ownership grant — so a freshly deployed Ekklesia has a supported way to get its first
* administrator (ADR 0035).
*
* This exists because the login page has nothing to log in to on a new installation. Public signup
* is disabled in every environment and production cannot enable it at all, which is correct and is
* not changed here; the consequence was simply that no supported path created the first account.
* This is that path, and it is deliberately the same shape as `pnpm authorization:bootstrap-owner`
* and `pnpm modules:enable`: explicit, operator-invoked, never HTTP-exposed, never inferred, and
* reachable only by someone who already holds the database credentials.
*
* It creates no HTTP route, starts no server, and applies no migration.
*/
class InitialOwnerCliError extends Error {
constructor(message: string) {
super(message);
this.name = "InitialOwnerCliError";
this.stack = `${this.name}: ${this.message}`;
}
}
const usage =
"Usage: pnpm identity:create-initial-owner -- --email <address> [--name <display name>]\n" +
"The password is never taken from the command line. It is prompted for when a terminal is\n" +
"attached, and otherwise read from standard input:\n" +
" printf '%s' \"$PASSWORD\" | pnpm identity:create-initial-owner -- --email [email protected]";
interface ParsedArgs {
readonly email: string;
readonly name?: string;
}
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts` and
* `set-module-enabled.ts`. There is deliberately no `--password`: see `readPassword`. */
function parseArgs(argv: readonly string[]): ParsedArgs {
let email: string | undefined;
let name: string | undefined;
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (flag === "--email") {
email = argv[index + 1];
index += 1;
} else if (flag === "--name") {
name = argv[index + 1];
index += 1;
} else if (flag === "--password" || flag?.startsWith("--password=") === true) {
// Refused explicitly rather than ignored, so an operator who tries it learns why instead of
// wondering why their password was not used.
throw new InitialOwnerCliError(
"--password is not supported: a command-line password is visible in shell history and in the process list.\n" +
usage,
);
}
}
if (email === undefined || email.trim().length === 0) {
throw new InitialOwnerCliError(usage);
}
return name === undefined ? { email } : { email, name };
}
/** Reads one line without echoing it. No dependency is added for this: the command needs exactly
* one hidden field, and Node's own raw-mode stdin is enough. */
function promptHidden(prompt: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const input = process.stdin;
process.stdout.write(prompt);
input.setRawMode(true);
input.resume();
input.setEncoding("utf8");
let value = "";
const finish = (settle: () => void): void => {
input.removeListener("data", onData);
input.setRawMode(false);
input.pause();
process.stdout.write("\n");
settle();
};
const onData = (chunk: string): void => {
for (const character of chunk) {
if (character === "\r" || character === "\n" || character === "\u0004") {
finish(() => resolve(value));
return;
}
if (character === "\u0003") {
finish(() => reject(new InitialOwnerCliError("Cancelled. No changes were made.")));
return;
}
// Backspace and delete, so a mistyped character can be corrected in a field nobody can see.
if (character === "\u007f" || character === "\b") {
value = value.slice(0, -1);
continue;
}
value += character;
}
};
input.on("data", onData);
});
}
/** Reads standard input to end, for automated deployment. */
function readStdin(): Promise<string> {
return new Promise<string>((resolve, reject) => {
let buffer = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk: string) => {
buffer += chunk;
});
process.stdin.on("end", () => resolve(buffer));
process.stdin.on("error", () =>
reject(new InitialOwnerCliError("Standard input could not be read.")),
);
});
}
/**
* Obtains the password without it ever appearing in `argv`, an environment variable, or the
* terminal.
*
* A command-line password is visible in shell history and to every other process on the machine via
* the process list, and an environment variable tends to become permanent — a bootstrap password
* belongs to one invocation, not to the deployment's configuration, so this command deliberately
* reads no environment variable for it and none is added to `.env.example`.
*
* Interactive use asks twice and compares, because the field is invisible and a typo would create
* an owner nobody can sign in as, on an installation whose one bootstrap has now been consumed.
*/
async function readPassword(): Promise<string> {
if (process.stdin.isTTY === true) {
const password = await promptHidden("Password: ");
const confirmation = await promptHidden("Confirm password: ");
if (password !== confirmation) {
throw new InitialOwnerCliError("The passwords did not match. No changes were made.");
}
return password;
}
// A single trailing newline is stripped so `printf '%s\n'`, a here-string, and an editor-produced
// file all behave the same; nothing else is trimmed, because leading and trailing spaces are
// legitimate password characters.
const piped = await readStdin();
return piped.replace(/\r?\n$/u, "");
}
async function main(): Promise<void> {
const { email, name } = parseArgs(process.argv.slice(2));
// Read only when this command actually executes — never merely by importing this module.
const databaseUrl = process.env.DATABASE_URL;
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
throw new InitialOwnerCliError("DATABASE_URL is required.");
}
const password = await readPassword();
if (password.length === 0) {
throw new InitialOwnerCliError("A password is required. No changes were made.");
}
// Asserted before any connection is constructed, so an unsafe production transport never opens a
// client at all — identical to every other Core executable.
const transport = resolveExecutableDatabaseTransport(databaseUrl);
const databaseOptions = {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 30_000,
transport,
};
const authorization = createAuthorizationDrizzleDatabase(databaseUrl, databaseOptions);
const identity = createIdentityDrizzleDatabase(databaseUrl, databaseOptions);
try {
const result = await createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
identityDb: identity.db,
},
{ email, name: name ?? email, password },
);
// Never the password, its length, a hash, a session token, a user id, or the database URL.
console.log(
result.identity === "created"
? "Initial platform owner created successfully."
: "An account already existed for that address; platform ownership was granted to it and its password was left unchanged.",
);
console.log(`Email: ${result.email}`);
console.log("You can now sign in to the Ekklesia dashboard.");
} finally {
await Promise.all([
authorization.close().catch(() => undefined),
identity.close().catch(() => undefined),
]);
}
}
function reportInitialOwnerCliError(error: unknown): void {
if (error instanceof InitialOwnerCliError) {
console.error(error.message);
return;
}
if (error instanceof InitialPlatformOwnerError) {
console.error(`[${error.code}] ${error.message}`);
return;
}
// Deliberately generic: the caught value may carry SQL, a connection string, a driver detail, or
// the password itself if it were ever interpolated into a message upstream.
console.error("Initial platform owner creation failed unexpectedly.");
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
main().catch((error: unknown) => {
reportInitialOwnerCliError(error);
process.exitCode = 1;
});
}
+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 };
} }
/** /**
+53 -54
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);
+183
View File
@@ -0,0 +1,183 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import {
createModuleLifecycleDatabase,
createModuleLifecycleStore,
discoverModules,
isDependencyBlockedDetail,
isStorageBlockedDetail,
ModuleDiscoveryError,
ModuleLifecycleStoreError,
resolveModuleEligibility,
} from "../modules/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
/**
* Enables or disables one installed module, out of band from the running API.
*
* This exists because module startup is deliberately all-or-nothing (ADR 0015, ADR 0020, preserved
* by ADR 0028): a module whose `start` hook throws takes API startup down with it, and the HTTP
* lifecycle API that could otherwise turn it off is served by that same process. Without an
* out-of-band path, the only recovery from one bad module would be editing the database by hand.
* This command is that path — the same shape as `pnpm authorization:bootstrap-owner`: explicit,
* operator-invoked, never HTTP-exposed, and never inferred.
*
* It writes configuration and nothing else. It starts nothing, stops nothing, imports no module
* server entrypoint, runs no lifecycle hook, and applies no migration. The change takes effect on
* the next API start.
*/
class ModuleLifecycleCliError extends Error {
constructor(message: string) {
super(message);
this.name = "ModuleLifecycleCliError";
this.stack = `${this.name}: ${this.message}`;
}
}
const usage =
"Usage: pnpm modules:enable -- --module <module-id> | pnpm modules:disable -- --module <module-id>";
interface ParsedArgs {
readonly enabled: boolean;
readonly moduleId: string;
}
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts`: this command
* takes one named flag plus one mode flag, so a general-purpose argument dependency is not
* warranted. `--enable` and `--disable` are mutually exclusive and one is required, so the intent
* is always stated rather than defaulted. */
function parseArgs(argv: readonly string[]): ParsedArgs {
let moduleId: string | undefined;
let enabled: boolean | undefined;
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (flag === "--module") {
moduleId = argv[index + 1];
index += 1;
} else if (flag === "--enable") {
if (enabled === false) {
throw new ModuleLifecycleCliError(usage);
}
enabled = true;
} else if (flag === "--disable") {
if (enabled === true) {
throw new ModuleLifecycleCliError(usage);
}
enabled = false;
}
}
if (moduleId === undefined || enabled === undefined) {
throw new ModuleLifecycleCliError(usage);
}
return { enabled, moduleId };
}
interface PackageVersion {
readonly version?: unknown;
}
async function readPackageVersion(packagePath: string): Promise<string> {
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
throw new ModuleLifecycleCliError("The repository package metadata could not be read.");
}
return packageData.version;
}
async function main(): Promise<void> {
const { enabled, moduleId } = parseArgs(process.argv.slice(2));
const repositoryRoot = process.cwd();
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
// The same rule the HTTP API enforces: a module this deployment cannot currently see may not be
// configured. Manifest-only, exactly like `pnpm modules:validate` — no module server entrypoint
// is imported and no lifecycle hook runs, so a module that crashes during `start` is still fully
// configurable here, which is the case this command exists for.
const discovered = await discoverModules({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
});
if (!discovered.some((module) => module.manifest.id === moduleId)) {
throw new ModuleLifecycleCliError(
`No installed module declares the id "${moduleId}". No changes were made.`,
);
}
// Read only when this command actually executes — never merely by importing this module.
const databaseUrl = process.env.DATABASE_URL;
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
throw new ModuleLifecycleCliError("DATABASE_URL is required.");
}
// Asserted before the connection is constructed, so an unsafe production transport never opens a
// client at all.
const transport = resolveExecutableDatabaseTransport(databaseUrl);
const { close, db } = createModuleLifecycleDatabase(databaseUrl, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
transport,
});
try {
const store = createModuleLifecycleStore(db);
await store.setEnabled({ enabled, moduleId });
const resolution = resolveModuleEligibility(discovered, await store.readConfiguration());
console.log(
`Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`,
);
for (const entry of resolution.entries) {
const blocked = entry.blocked;
if (!blocked) {
continue;
}
// Names the dependency, the configuration key, or the logical storage bucket — never a
// configuration value, an endpoint, or a credential.
const cause = isDependencyBlockedDetail(blocked)
? blocked.dependency.moduleId
: isStorageBlockedDetail(blocked)
? blocked.storageBucketId
: blocked.configurationKey;
console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`);
}
} finally {
await close().catch(() => undefined);
}
}
function reportCliError(error: unknown): void {
if (error instanceof ModuleLifecycleCliError) {
console.error(error.message);
return;
}
if (error instanceof ModuleLifecycleStoreError) {
console.error(`[${error.code}] ${error.message}`);
return;
}
if (error instanceof ModuleDiscoveryError) {
console.error(error.message);
for (const issue of error.issues) {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
}
return;
}
console.error("The module enablement change failed.");
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
main().catch((error: unknown) => {
reportCliError(error);
process.exitCode = 1;
});
}
+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"
}`,
);
}
} }
} }
+3
View File
@@ -0,0 +1,3 @@
export * from "./navigation.js";
export * from "./schema.js";
export * from "./store.js";
+156
View File
@@ -0,0 +1,156 @@
import type { DashboardConfiguration } from "./store.js";
/**
* One module as the Dashboard needs to present it: its identity, its administrator-visible name,
* and nothing about where it lives on disk.
*
* `name` is the module's own `manifest.name` — its product identity, which every manifest has
* already required since version 1. The Dashboard renders it rather than the module id, and a module
* declaring it is *not* the module declaring its navigation: a name answers "what is this product
* called", while a group answers "where does this church want it", and only the second is
* configuration an administrator owns.
*/
export interface DashboardNavigationModule {
readonly id: string;
readonly name: string;
}
export interface DashboardNavigationGroupView<Module extends DashboardNavigationModule> {
/** The stable key, unchanged by a rename. Callers use it to address the group, never to display. */
readonly id: string;
readonly label: string;
readonly order: number;
readonly modules: readonly Module[];
}
export interface DashboardNavigationView<Module extends DashboardNavigationModule> {
readonly groups: readonly DashboardNavigationGroupView<Module>[];
/**
* Modules that belong to no group: those an administrator deliberately placed outside every group,
* and those never configured at all — a newly installed module, most importantly. Both render
* here, which is what guarantees a new module is visible the moment it is discovered, with no
* initialization write, no manual database edit, and no migration.
*/
readonly ungrouped: readonly Module[];
}
/** Placed modules first, in the administrator's order; never-configured modules after them, by id.
* A newcomer therefore appears without interrupting an ordering someone chose, and the module id is
* always the final tie-break, so two modules sharing a `sortOrder` still order identically on every
* request and in every process. */
interface SortKey {
readonly configured: 0 | 1;
readonly order: number;
readonly id: string;
}
function compareSortKeys(left: SortKey, right: SortKey): number {
if (left.configured !== right.configured) {
return left.configured - right.configured;
}
if (left.order !== right.order) {
return left.order - right.order;
}
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
}
/**
* Resolves stored Dashboard configuration against the modules that are actually mountable right now.
*
* Pure and synchronous: no database, no filesystem, no clock, no module code. It is given the stored
* arrangement and the current module set and returns what to render, so every rule below is
* directly testable without PostgreSQL.
*
* ## The rules, exactly
*
* - **A module with no placement is ungrouped and visible.** Nothing is written to make that true.
* Installing a module is enough to see it; an administrator moves it afterwards if they want to.
* - **A stored placement for a module that is not installed renders nothing, and is retained.** The
* store keeps the row, so uninstalling and reinstalling a module restores its position rather than
* silently resetting it. A dead navigation entry never appears.
* - **A placement naming a group that no longer exists cannot occur** — the schema's
* `on delete set null` makes the module ungrouped when its group is deleted — but if one somehow
* did, it is treated as ungrouped rather than dropped, so no module can be made invisible by a
* configuration inconsistency.
* - **Empty groups are kept.** A group an administrator created and has not filled yet, or whose
* only module is temporarily uninstalled, still renders; hiding it would look like the group had
* been deleted.
* - **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)`. Colliding
* order values never produce a nondeterministic result.
*
* ## What this function structurally cannot do
*
* It never reads a manifest, a dependency, a capability, an entrypoint, or a permission, and it
* returns no edge between two modules. Two modules sharing a group are exactly as related as two
* modules that do not: grouping is a rendering decision computed here, downstream of everything that
* decides what a module *is* (ADR 0027). Nothing upstream — discovery, dependency ordering, runtime
* startup, authorization — ever calls this.
*/
export function resolveDashboardNavigation<Module extends DashboardNavigationModule>(
configuration: DashboardConfiguration,
mountableModules: readonly Module[],
): DashboardNavigationView<Module> {
const placementsByModuleId = new Map(
configuration.placements.map((placement) => [placement.moduleId, placement] as const),
);
const groupIds = new Set(configuration.groups.map((group) => group.id));
const modulesByGroupId = new Map<string, Array<{ key: SortKey; module: Module }>>();
const ungrouped: Array<{ key: SortKey; module: Module }> = [];
for (const module of mountableModules) {
const placement = placementsByModuleId.get(module.id);
const key: SortKey = {
configured: placement ? 0 : 1,
id: module.id,
order: placement?.sortOrder ?? 0,
};
const groupId =
placement?.groupId !== undefined &&
placement.groupId !== null &&
groupIds.has(placement.groupId)
? placement.groupId
: undefined;
if (groupId === undefined) {
ungrouped.push({ key, module });
continue;
}
const bucket = modulesByGroupId.get(groupId);
if (bucket) {
bucket.push({ key, module });
} else {
modulesByGroupId.set(groupId, [{ key, module }]);
}
}
const sortModules = (entries: Array<{ key: SortKey; module: Module }>): readonly Module[] =>
Object.freeze(
[...entries]
.sort((left, right) => compareSortKeys(left.key, right.key))
.map((entry) => entry.module),
);
const groups = [...configuration.groups]
.sort((left, right) =>
left.sortOrder !== right.sortOrder
? left.sortOrder - right.sortOrder
: left.id < right.id
? -1
: left.id > right.id
? 1
: 0,
)
.map((group) =>
Object.freeze({
id: group.id,
label: group.label,
modules: sortModules(modulesByGroupId.get(group.id) ?? []),
order: group.sortOrder,
}),
);
return Object.freeze({
groups: Object.freeze(groups),
ungrouped: sortModules(ungrouped),
});
}
+95
View File
@@ -0,0 +1,95 @@
import { sql } from "drizzle-orm";
import { check, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
/**
* Core-owned storage for how the Ekklesia Dashboard *presents* installed modules.
*
* This is presentation configuration, and it is deliberately the only thing here. A group owns no
* tables, no migrations, no routes, and no lifecycle; it never participates in dependency
* resolution, discovery order, or module startup. **Modules stay flat** (ADR 0021): there is no
* parent module, no child module, no owning module, and nothing in this schema is ever read by
* anything that decides what a module may do or when it starts. Moving a module between groups
* changes a row here and nothing else.
*
* Core stores this; it renders nothing. `surfaces/shell` decides what the arrangement looks like.
*
* Platform-global, because Ekklesia has no organization or tenant model to scope it to. When one
* exists, these two tables gain a scope column; inventing a second notion of organization here to
* anticipate it would be worse than adding a column later.
*
* Table and index names are explicit and namespaced exactly like `core/src/authorization/schema.ts`,
* so ownership is unambiguous inside the shared public PostgreSQL schema.
*/
/**
* A Dashboard navigation group — "Media", "Planning", "Sunday Production".
*
* `id` is an **opaque, stable key**, never displayed and never re-derived from `label`. Renaming a
* group changes only `label`, so every placement pointing at it survives untouched; that invariant
* is what keeps a rename from silently re-organizing the Dashboard. A slug is used rather than a
* generated UUID purely so the configuration API and its tests stay legible — it carries no meaning
* the platform reads.
*
* There is no parent group column. Nesting is deliberately out of scope, and leaving the shape flat
* is what keeps it out.
*/
export const ekklesiaCoreDashboardGroup = pgTable(
"ekklesia_core_dashboard_group",
{
id: text("id").primaryKey(),
label: text("label").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_dashboard_group_order_idx").on(table.sortOrder, table.id),
// Defense in depth beneath the application-layer identifier and label contracts, which never
// produce either of these: an empty id would be an unaddressable group, and an empty or
// whitespace-only label would render as a blank heading no administrator could find again.
check("ekklesia_core_dashboard_group_id_check", sql`length(${table.id}) > 0`),
check("ekklesia_core_dashboard_group_label_check", sql`length(btrim(${table.label})) > 0`),
],
);
/**
* Where one module sits in the Dashboard. One row per module at most — the primary key enforces it
* — so a module can never be placed in two groups, which is the database-level expression of "one
* level of grouping, and no module ownership graph."
*
* `group_id` is nullable and that is a real state, not a missing value: **ungrouped is a placement**,
* chosen by an administrator who ordered a module outside every group. A module with no row at all
* is a different state — never configured — and both render in the same ungrouped area (see
* `../dashboard/navigation.js` for the exact ordering rule).
*
* `on delete set null` is what makes group deletion safe: deleting a group cannot leave a dangling
* reference, and its modules become ungrouped rather than disappearing. That is enforced by the
* database, not by application cleanup that could be skipped.
*
* `module_id` deliberately carries **no foreign key to any module**, because modules are not rows —
* they are packages discovered on disk at startup. A placement for a module that is not installed
* is therefore normal and is retained: uninstalling and reinstalling a module keeps its position
* rather than silently resetting it. Nothing renders such a row (see `resolveDashboardNavigation`).
*/
export const ekklesiaCoreDashboardPlacement = pgTable(
"ekklesia_core_dashboard_placement",
{
moduleId: text("module_id").primaryKey(),
groupId: text("group_id").references(() => ekklesiaCoreDashboardGroup.id, {
onDelete: "set null",
}),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_dashboard_placement_group_idx").on(table.groupId, table.sortOrder),
check("ekklesia_core_dashboard_placement_module_check", sql`length(${table.moduleId}) > 0`),
],
);
/** Explicit map passed to Drizzle, mirroring `authorizationSchema`'s explicit-mapping style. */
export const dashboardSchema = {
dashboardGroup: ekklesiaCoreDashboardGroup,
dashboardPlacement: ekklesiaCoreDashboardPlacement,
} as const;
+314
View File
@@ -0,0 +1,314 @@
import { asc, eq } from "drizzle-orm";
import type { Pool } from "pg";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import {
dashboardSchema,
ekklesiaCoreDashboardGroup,
ekklesiaCoreDashboardPlacement,
} from "./schema.js";
export type DashboardDrizzleDatabase = NodePgDatabase<typeof dashboardSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`:
* it never constructs, registers an error listener on, or closes a pool. The caller owns the pool
* once, however many typed views it creates over it.
*/
export function createDashboardDrizzleView(pool: Pool): DashboardDrizzleDatabase {
return drizzle(pool, { schema: dashboardSchema });
}
/** Bounds every stored ordering value. Wide enough that an administrator never runs out of room
* between two entries, small enough that a client cannot store a value that surprises a later
* integer column. Enforced identically by the store and by the HTTP surface, so there is one bound
* rather than two that could drift. */
export const DASHBOARD_SORT_ORDER_MIN = 0;
export const DASHBOARD_SORT_ORDER_MAX = 1_000_000;
/** The label an administrator sees. Bounded like every other operator-facing string in the
* platform; `moduleManifestSchema.name` uses the same ceiling. */
export const DASHBOARD_GROUP_LABEL_MAX_LENGTH = 120;
/** Lowercase kebab-case, matching `moduleIdSchema`'s grammar so the platform has one identifier
* shape rather than two. Opaque: never displayed, never parsed for meaning, never re-derived from a
* label. */
const GROUP_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const GROUP_ID_MAX_LENGTH = 64;
/** The one identifier grammar for both a group id and a placement's module id, exported so the HTTP
* boundary rejects malformed input with a request error *before* reaching the store rather than
* relying on mapping a store failure back to a status code — and so there is one grammar rather
* than two that could drift. */
export function isValidDashboardIdentifier(value: unknown): value is string {
return (
typeof value === "string" && value.length <= GROUP_ID_MAX_LENGTH && GROUP_ID_PATTERN.test(value)
);
}
/** The one label rule, exported for the same reason. Returns the trimmed value the store would
* persist, or `undefined` when the label is empty, whitespace-only, or too long. */
export function normalizeDashboardGroupLabel(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 || trimmed.length > DASHBOARD_GROUP_LABEL_MAX_LENGTH
? undefined
: trimmed;
}
export type DashboardStoreErrorCode =
| "DASHBOARD_GROUP_ID_INVALID"
| "DASHBOARD_GROUP_LABEL_INVALID"
| "DASHBOARD_GROUP_NOT_FOUND"
| "DASHBOARD_MODULE_ID_INVALID"
| "DASHBOARD_SORT_ORDER_INVALID"
| "DASHBOARD_READ_FAILED"
| "DASHBOARD_WRITE_FAILED";
const storeErrorMessages: Readonly<Record<DashboardStoreErrorCode, string>> = Object.freeze({
DASHBOARD_GROUP_ID_INVALID: "A Dashboard group identifier must be lowercase kebab-case.",
DASHBOARD_GROUP_LABEL_INVALID: "A Dashboard group label must be non-empty and bounded.",
DASHBOARD_GROUP_NOT_FOUND: "The Dashboard group was not found.",
DASHBOARD_MODULE_ID_INVALID: "A Dashboard placement module identifier is invalid.",
DASHBOARD_READ_FAILED: "The Dashboard navigation configuration could not be read.",
DASHBOARD_SORT_ORDER_INVALID: "A Dashboard ordering value is out of range.",
DASHBOARD_WRITE_FAILED: "The Dashboard navigation configuration could not be saved.",
});
/** Never carries the underlying database error, SQL text, host, or stack — matching
* `AuthorizationStoreError` exactly. */
export class DashboardStoreError extends Error {
readonly code: DashboardStoreErrorCode;
constructor(code: DashboardStoreErrorCode) {
super(storeErrorMessages[code]);
this.name = "DashboardStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** A stored group. `label` is what an administrator sees; `id` is the stable key placements point
* at, and a rename never touches it. */
export interface DashboardGroupRecord {
readonly id: string;
readonly label: string;
readonly sortOrder: number;
}
/** Where one module sits. `groupId: null` is a real, chosen state — deliberately ungrouped — and is
* distinct from having no record at all, which means never configured. */
export interface DashboardPlacementRecord {
readonly moduleId: string;
readonly groupId: string | null;
readonly sortOrder: number;
}
/** The complete stored arrangement. Contains no module facts whatsoever: resolving it against the
* modules that actually exist is `resolveDashboardNavigation`'s job, not the store's. */
export interface DashboardConfiguration {
readonly groups: readonly DashboardGroupRecord[];
readonly placements: readonly DashboardPlacementRecord[];
}
export interface UpsertDashboardGroupInput {
readonly id: string;
readonly label: string;
readonly sortOrder: number;
}
export interface SetDashboardPlacementInput {
readonly moduleId: string;
/** `null` places the module in the ungrouped area deliberately. A group id that does not exist is
* rejected rather than stored, so a placement can never point at a group that was never created. */
readonly groupId: string | null;
readonly sortOrder: number;
}
export interface DashboardStore {
/** The whole stored arrangement, in deterministic order. Small by nature — one row per group and
* at most one per module — so it is read whole rather than paginated. */
readConfiguration(): Promise<DashboardConfiguration>;
/** Creates the group or replaces its label and order. Idempotent: the same input twice leaves the
* same single row, and placements pointing at it are untouched either way. */
upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord>;
/** Deletes a group. Its placements become ungrouped through the schema's `on delete set null`, so
* no dangling reference is possible and no module is lost. */
deleteGroup(groupId: string): Promise<void>;
/** Creates or replaces one module's placement. */
setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord>;
}
function assertValidGroupId(value: string): void {
if (!isValidDashboardIdentifier(value)) {
throw new DashboardStoreError("DASHBOARD_GROUP_ID_INVALID");
}
}
function assertValidModuleId(value: string): void {
if (!isValidDashboardIdentifier(value)) {
throw new DashboardStoreError("DASHBOARD_MODULE_ID_INVALID");
}
}
function normalizedLabel(value: string): string {
const trimmed = normalizeDashboardGroupLabel(value);
if (trimmed === undefined) {
throw new DashboardStoreError("DASHBOARD_GROUP_LABEL_INVALID");
}
return trimmed;
}
function assertValidSortOrder(value: number): void {
if (
!Number.isSafeInteger(value) ||
value < DASHBOARD_SORT_ORDER_MIN ||
value > DASHBOARD_SORT_ORDER_MAX
) {
throw new DashboardStoreError("DASHBOARD_SORT_ORDER_INVALID");
}
}
function rethrowAsStoreError(error: unknown, code: DashboardStoreErrorCode): never {
if (error instanceof DashboardStoreError) {
throw error;
}
throw new DashboardStoreError(code);
}
/**
* Core's persistence for Dashboard navigation configuration.
*
* Validation lives here rather than only at the HTTP boundary, so the invariants hold for any
* caller: identifiers are checked against one grammar, labels are trimmed and bounded, ordering is
* a bounded safe integer, and a placement may only name a group that exists. Nothing here reads,
* imports, or reasons about a module — a placement is a `module_id` string and no more — which is
* what keeps grouping structurally incapable of influencing discovery, dependency resolution, or
* startup order.
*/
export function createDashboardStore(db: DashboardDrizzleDatabase): DashboardStore {
return Object.freeze({
async readConfiguration(): Promise<DashboardConfiguration> {
try {
const [groups, placements] = await Promise.all([
db
.select({
id: ekklesiaCoreDashboardGroup.id,
label: ekklesiaCoreDashboardGroup.label,
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
})
.from(ekklesiaCoreDashboardGroup)
.orderBy(asc(ekklesiaCoreDashboardGroup.sortOrder), asc(ekklesiaCoreDashboardGroup.id)),
db
.select({
groupId: ekklesiaCoreDashboardPlacement.groupId,
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
})
.from(ekklesiaCoreDashboardPlacement)
.orderBy(
asc(ekklesiaCoreDashboardPlacement.sortOrder),
asc(ekklesiaCoreDashboardPlacement.moduleId),
),
]);
return Object.freeze({
groups: Object.freeze(groups.map((group) => Object.freeze({ ...group }))),
placements: Object.freeze(placements.map((placement) => Object.freeze({ ...placement }))),
});
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_READ_FAILED");
}
},
async upsertGroup(input: UpsertDashboardGroupInput): Promise<DashboardGroupRecord> {
assertValidGroupId(input.id);
const label = normalizedLabel(input.label);
assertValidSortOrder(input.sortOrder);
try {
// A rename updates `label` and `sort_order` only. `id` is the conflict target, so it is
// never rewritten and every placement pointing at this group keeps pointing at it.
const [row] = await db
.insert(ekklesiaCoreDashboardGroup)
.values({ id: input.id, label, sortOrder: input.sortOrder })
.onConflictDoUpdate({
set: { label, sortOrder: input.sortOrder, updatedAt: new Date() },
target: ekklesiaCoreDashboardGroup.id,
})
.returning({
id: ekklesiaCoreDashboardGroup.id,
label: ekklesiaCoreDashboardGroup.label,
sortOrder: ekklesiaCoreDashboardGroup.sortOrder,
});
if (!row) {
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
}
return Object.freeze(row);
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
async deleteGroup(groupId: string): Promise<void> {
assertValidGroupId(groupId);
try {
const deleted = await db
.delete(ekklesiaCoreDashboardGroup)
.where(eq(ekklesiaCoreDashboardGroup.id, groupId))
.returning({ id: ekklesiaCoreDashboardGroup.id });
if (deleted.length === 0) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
async setPlacement(input: SetDashboardPlacementInput): Promise<DashboardPlacementRecord> {
assertValidModuleId(input.moduleId);
assertValidSortOrder(input.sortOrder);
if (input.groupId !== null) {
assertValidGroupId(input.groupId);
}
try {
// Checked in the application rather than left to the foreign key so the caller gets
// `DASHBOARD_GROUP_NOT_FOUND` instead of a driver error. The foreign key remains the
// authority: a group deleted concurrently still fails the insert rather than storing a
// dangling reference.
if (input.groupId !== null) {
const existing = await db
.select({ id: ekklesiaCoreDashboardGroup.id })
.from(ekklesiaCoreDashboardGroup)
.where(eq(ekklesiaCoreDashboardGroup.id, input.groupId))
.limit(1);
if (existing.length === 0) {
throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND");
}
}
const [row] = await db
.insert(ekklesiaCoreDashboardPlacement)
.values({
groupId: input.groupId,
moduleId: input.moduleId,
sortOrder: input.sortOrder,
})
.onConflictDoUpdate({
set: { groupId: input.groupId, sortOrder: input.sortOrder, updatedAt: new Date() },
target: ekklesiaCoreDashboardPlacement.moduleId,
})
.returning({
groupId: ekklesiaCoreDashboardPlacement.groupId,
moduleId: ekklesiaCoreDashboardPlacement.moduleId,
sortOrder: ekklesiaCoreDashboardPlacement.sortOrder,
});
if (!row) {
throw new DashboardStoreError("DASHBOARD_WRITE_FAILED");
}
return Object.freeze(row);
} catch (error) {
rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED");
}
},
});
}
+7 -6
View File
@@ -191,12 +191,13 @@ export function assertNoConnectionStringTlsParameters(databaseUrl: string): void
* *
* `verify-full` is **not** simply `ssl: true` or `{ rejectUnauthorized: true }`. Verified directly * `verify-full` is **not** simply `ssl: true` or `{ rejectUnauthorized: true }`. Verified directly
* against a live TLS PostgreSQL instance: those verify the certificate *chain*, but `pg` only sets * against a live TLS PostgreSQL instance: those verify the certificate *chain*, but `pg` only sets
* `options.servername` for non-IP hosts (`pg/lib/connection.js`), and Node performs no identity * `options.servername` for non-IP hosts (`pg/lib/connection.js`) and passes `tls.connect` a bare
* check at all when `servername` is unset — so a certificate issued for an entirely different name * socket carrying no host, so for an IP endpoint Node has no identity to check and falls back to
* is accepted when connecting to an IP address. Supplying an explicit `checkServerIdentity` pinned * the fixed name `localhost` — never the address actually dialled. A certificate issued for an
* to the configured host closes that gap for DNS and IP hosts alike (proven: a certificate valid * entirely different name is therefore accepted when connecting to an IP address. Supplying an
* only for `localhost` is rejected with `ERR_TLS_CERT_ALTNAME_INVALID` when reached via * explicit `checkServerIdentity` pinned to the configured host closes that gap for DNS and IP hosts
* `127.0.0.1`). * alike (proven: a certificate valid only for `localhost` is rejected with
* `ERR_TLS_CERT_ALTNAME_INVALID` when reached via `127.0.0.1`).
*/ */
export function databaseTlsConnectionOptions( export function databaseTlsConnectionOptions(
databaseUrl: string, databaseUrl: string,
+16
View File
@@ -0,0 +1,16 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { deviceSchema } from "./schema.js";
export type DeviceDrizzleDatabase = NodePgDatabase<typeof deviceSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`.
*
* There is deliberately no pool-constructing counterpart: device identity is only ever read and
* written by the running API, which already owns one validated pool. A second constructor would be
* a second place for transport policy to drift.
*/
export function createDeviceDrizzleView(pool: Pool): DeviceDrizzleDatabase {
return drizzle(pool, { schema: deviceSchema });
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./database.js";
export * from "./schema.js";
export * from "./store.js";
+168
View File
@@ -0,0 +1,168 @@
import { relations, sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned device identity: paired display devices, the permissions granted to them, and the
* short-lived pairing requests that create them (ADR 0036).
*
* **Devices are not users.** There is deliberately no foreign key from a device to
* `ekklesia_core_user`, and no device row ever appears in the identity tables: a device is its own
* principal class with its own credential and its own explicit permissions. The only reference to a
* user here is *who approved a pairing*, which is audit information about a person's action, not a
* device's identity.
*
* Device permissions live in their own table rather than in `ekklesia_core_grant`. That table's
* `user_id` is a non-null foreign key into the user table, and generalizing it to a polymorphic
* subject would rewrite the platform's central authorization record — and its relationship-derived
* grants, audit semantics, and indexes — to serve one narrow case. A separate table keeps the
* device story additive and auditable while the evaluator remains the single place any decision is
* made (`core/src/authorization/evaluator.ts`).
*/
export const ekklesiaCoreDevice = pgTable(
"ekklesia_core_device",
{
id: uuid("id").primaryKey().defaultRandom(),
/** Assigned by the approving administrator, never self-asserted by the unpaired device. */
name: text("name").notNull(),
/**
* SHA-256 of the device's credential, hex encoded. The raw credential exists only in the
* response that sets the cookie and in the device's own cookie jar; it is never stored, logged,
* or recoverable from this row. A fast hash is right here — unlike a password, the credential
* is 256 bits of CSPRNG output, so there is nothing to brute-force and a slow KDF would only
* add latency to every request a display makes.
*/
credentialHash: text("credential_hash").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
createdByUserId: text("created_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
/** Coarse operational signal, throttled by the resolver; never an identity input. */
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
/** Server-side authority. Revocation is this column, never the deletion of a remote cookie. */
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByUserId: text("revoked_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
},
(table) => [
// Every credential is distinct, and the resolver looks a presented credential up by this exact
// hash — an index probe rather than a scan-and-compare, so there is no comparison to time.
uniqueIndex("ekklesia_core_device_credential_hash_idx").on(table.credentialHash),
index("ekklesia_core_device_revoked_idx").on(table.revokedAt),
check("ekklesia_core_device_name_check", sql`length(${table.name}) between 1 and 128`),
check("ekklesia_core_device_credential_hash_check", sql`length(${table.credentialHash}) = 64`),
],
);
export const ekklesiaCoreDevicePermission = pgTable(
"ekklesia_core_device_permission",
{
id: uuid("id").primaryKey().defaultRandom(),
deviceId: uuid("device_id")
.notNull()
.references(() => ekklesiaCoreDevice.id, { onDelete: "cascade" }),
permissionId: text("permission_id").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
grantedByUserId: text("granted_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
},
(table) => [
index("ekklesia_core_device_permission_lookup_idx").on(table.deviceId, table.permissionId),
check(
"ekklesia_core_device_permission_id_check",
sql`length(${table.permissionId}) between 1 and 128`,
),
],
);
/**
* One in-flight pairing attempt.
*
* Three distinct secrets meet here and must not be confused:
*
* - `code` is the short digits shown on the screen. It is **public by design** — it is displayed on
* a television in a hallway — and it identifies the request to an administrator. It is stored in
* the clear because it is not a secret and because an administrator looks a request up by it.
* - `claimSecretHash` is SHA-256 of a 256-bit secret held only in the requesting browser's
* HttpOnly cookie. It answers "are you the device that asked?", which the code cannot: anyone who
* can *see* the screen knows the code, so a flow that issued a credential on the strength of the
* code alone would hand the device's identity to whoever read it off the wall.
* - The device credential does not exist yet, and is created only when the approved request is
* consumed by the browser holding the claim secret.
*
* `expiresAt` is the only expiry authority; there is no `expired` state to write and therefore no
* sweeper that must run for correctness. `state` records only what someone *did* — nothing here
* ages a row on its own.
*/
export const ekklesiaCoreDevicePairingRequest = pgTable(
"ekklesia_core_device_pairing_request",
{
id: uuid("id").primaryKey().defaultRandom(),
code: text("code").notNull(),
claimSecretHash: text("claim_secret_hash").notNull(),
/** The display the device actually navigated to, resolved server-side from the publication
* registry. Never a value the browser chose. */
moduleId: text("module_id").notNull(),
/** Derived from that module's validated display declaration, so a device cannot ask for a
* permission of its choosing. */
permissionId: text("permission_id").notNull(),
state: text("state").notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
/** Chosen by the approving administrator; carried here until the request is consumed and the
* device row is created. */
deviceName: text("device_name"),
approvedAt: timestamp("approved_at", { withTimezone: true }),
approvedByUserId: text("approved_by_user_id").references(() => ekklesiaCoreUser.id, {
onDelete: "set null",
}),
consumedAt: timestamp("consumed_at", { withTimezone: true }),
deviceId: uuid("device_id").references(() => ekklesiaCoreDevice.id, { onDelete: "set null" }),
},
(table) => [
// At most one live request may hold a given code, which is what makes a short code safe to
// generate: a collision is refused by PostgreSQL rather than resolved by whichever row a query
// happened to return first. Consumed rows keep their code without blocking reuse later.
uniqueIndex("ekklesia_core_device_pairing_code_idx")
.on(table.code)
.where(sql`${table.state} <> 'consumed'`),
index("ekklesia_core_device_pairing_claim_idx").on(table.claimSecretHash),
check("ekklesia_core_device_pairing_code_check", sql`${table.code} ~ '^[0-9]{6}$'`),
check(
"ekklesia_core_device_pairing_claim_hash_check",
sql`length(${table.claimSecretHash}) = 64`,
),
check(
"ekklesia_core_device_pairing_state_check",
sql`${table.state} in ('pending', 'approved', 'consumed')`,
),
],
);
export const ekklesiaCoreDeviceRelations = relations(ekklesiaCoreDevice, ({ many }) => ({
permissions: many(ekklesiaCoreDevicePermission),
}));
export const ekklesiaCoreDevicePermissionRelations = relations(
ekklesiaCoreDevicePermission,
({ one }) => ({
device: one(ekklesiaCoreDevice, {
fields: [ekklesiaCoreDevicePermission.deviceId],
references: [ekklesiaCoreDevice.id],
}),
}),
);
/** The device tables, bundled exactly like `authorizationSchema`, so a Drizzle view over the
* platform's shared pool is typed against them. */
export const deviceSchema = {
ekklesiaCoreDevice,
ekklesiaCoreDevicePairingRequest,
ekklesiaCoreDevicePermission,
ekklesiaCoreDevicePermissionRelations,
ekklesiaCoreDeviceRelations,
};
+552
View File
@@ -0,0 +1,552 @@
import { createHash, randomBytes, randomInt } from "node:crypto";
import type { EkklesiaDeviceId, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm";
import type { DeviceDrizzleDatabase } from "./database.js";
import {
ekklesiaCoreDevice,
ekklesiaCoreDevicePairingRequest,
ekklesiaCoreDevicePermission,
} from "./schema.js";
export type DeviceStoreErrorCode =
| "DEVICE_PAIRING_CODE_UNAVAILABLE"
| "DEVICE_STORE_FAILED"
| "DEVICE_NOT_FOUND";
const deviceStoreMessages: Readonly<Record<DeviceStoreErrorCode, string>> = Object.freeze({
DEVICE_NOT_FOUND: "The device does not exist.",
DEVICE_PAIRING_CODE_UNAVAILABLE: "A pairing code could not be allocated. Try again.",
DEVICE_STORE_FAILED: "The device operation could not be completed.",
});
/** Never carries a credential, a claim secret, SQL, a driver error, or a cause. */
export class DeviceStoreError extends Error {
readonly code: DeviceStoreErrorCode;
constructor(code: DeviceStoreErrorCode) {
super(deviceStoreMessages[code]);
this.name = "DeviceStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* How long a pairing code is worth showing on a screen.
*
* Long enough that someone can walk from the display to a computer and type six digits; short
* enough that a code photographed off a wall is worthless by the time anyone acts on it. It is not
* the only thing protecting pairing — approval requires an authenticated administrator holding
* `core.devices.manage`, and the credential is issued only to the browser holding the claim secret
* — so this bound is defence in depth rather than the defence.
*/
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
/**
* How long a device credential is accepted before the device must pair again.
*
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
* decides on every single request whether the device is still revoked, so an administrator's revoke
* takes effect immediately regardless of how long the credential would otherwise have lived. The
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
*/
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
const PAIRING_CODE_ALLOCATION_ATTEMPTS = 8;
/**
* How many pairing requests may be waiting at once, across the whole installation.
*
* Starting a pairing needs no identity — an unpaired screen has none to offer — so without a ceiling
* anyone who can reach the platform could fill this table. A building has a handful of screens and
* pairs them one at a time, so this is far above any real use and still a hard bound. Expired rows
* are swept on the way in, so the ceiling counts genuinely live requests rather than history.
*/
const MAX_LIVE_PAIRING_REQUESTS = 50;
/** SHA-256, hex. Applied to 256-bit CSPRNG output, never to anything user-chosen. */
export function hashDeviceSecret(secret: string): string {
return createHash("sha256").update(secret, "utf8").digest("hex");
}
/** 256 bits, URL-safe. Used for both the pairing claim secret and the device credential — the two
* values that must never be guessable. */
function generateSecret(): string {
return randomBytes(32).toString("base64url");
}
/** Six digits from a rejection-free CSPRNG range, zero padded so every code is exactly six
* characters and none is easier to type than another. */
function generatePairingCode(): string {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export interface CreatePairingRequestInput {
readonly moduleId: string;
readonly permissionId: PermissionId;
readonly now: Date;
}
export interface CreatedPairingRequest {
readonly code: string;
readonly expiresAt: Date;
/** Returned exactly once, to be written into the device's HttpOnly cookie and never persisted or
* logged in this form. */
readonly claimSecret: string;
}
export type PairingRequestStatus = "pending" | "approved" | "expired" | "unknown";
export interface PairingRequestView {
readonly moduleId: string;
readonly permissionId: string;
readonly createdAt: Date;
readonly expiresAt: Date;
readonly status: "pending" | "approved";
}
export interface ConsumedPairing {
readonly deviceId: EkklesiaDeviceId;
/** Returned exactly once, to be written into the device's HttpOnly cookie. */
readonly credential: string;
readonly expiresAt: Date;
}
export interface DeviceSummary {
readonly id: EkklesiaDeviceId;
readonly name: string;
readonly createdAt: Date;
readonly lastSeenAt?: Date;
readonly revokedAt?: Date;
readonly permissions: readonly string[];
}
export interface ResolvedDevice {
readonly deviceId: EkklesiaDeviceId;
readonly lastSeenAt?: Date;
}
export interface DeviceStore {
createPairingRequest(input: CreatePairingRequestInput): Promise<CreatedPairingRequest>;
/** Looks a live request up by the code an administrator typed. Never used by the device itself. */
findPendingByCode(code: string, now: Date): Promise<PairingRequestView | undefined>;
approvePairingRequest(input: {
readonly code: string;
readonly deviceName: string;
readonly approvedBy: EkklesiaUserId;
readonly now: Date;
}): Promise<PairingRequestView | undefined>;
/** The device's own poll. Consumes an approved request atomically and issues the credential. */
claimPairingRequest(input: {
readonly claimSecret: string;
readonly now: Date;
}): Promise<{ readonly status: PairingRequestStatus; readonly paired?: ConsumedPairing }>;
/** Resolves a presented credential to a live, unrevoked device. */
resolveCredential(credential: string, now: Date): Promise<ResolvedDevice | undefined>;
touchLastSeen(deviceId: EkklesiaDeviceId, now: Date): Promise<void>;
hasActivePermission(deviceId: EkklesiaDeviceId, permission: PermissionId): Promise<boolean>;
listDevices(): Promise<readonly DeviceSummary[]>;
revokeDevice(input: {
readonly deviceId: EkklesiaDeviceId;
readonly revokedBy: EkklesiaUserId;
readonly now: Date;
}): Promise<void>;
}
function rethrowAsStoreError(error: unknown): never {
if (error instanceof DeviceStoreError) {
throw error;
}
throw new DeviceStoreError("DEVICE_STORE_FAILED");
}
export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
/** Deletes rows that can no longer do anything: expired-and-never-approved requests, and
* consumed ones past their window. Run opportunistically on request creation rather than by a
* scheduler — pairing is rare, so the only moment the table can grow is also a fine moment to
* trim it, and nothing depends on it having run. */
const sweepDeadRequests = async (now: Date): Promise<void> => {
await db
.delete(ekklesiaCoreDevicePairingRequest)
.where(
or(
and(
lt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
),
lt(ekklesiaCoreDevicePairingRequest.expiresAt, new Date(now.getTime() - 86_400_000)),
),
)
.catch(() => undefined);
};
const store: DeviceStore = {
async approvePairingRequest(input): Promise<PairingRequestView | undefined> {
try {
// Conditional on `pending` and unexpired, so approving twice, approving an expired request,
// or approving one already consumed all affect zero rows rather than racing.
const rows = await db
.update(ekklesiaCoreDevicePairingRequest)
.set({
approvedAt: input.now,
approvedByUserId: input.approvedBy,
deviceName: input.deviceName,
state: "approved",
})
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.code, input.code),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
),
)
.returning({
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
});
const row = rows[0];
return row === undefined
? undefined
: Object.freeze({ ...row, status: "approved" as const });
} catch (error) {
rethrowAsStoreError(error);
}
},
async claimPairingRequest(input): Promise<{
readonly status: PairingRequestStatus;
readonly paired?: ConsumedPairing;
}> {
const claimSecretHash = hashDeviceSecret(input.claimSecret);
try {
return await db.transaction(async (tx) => {
const rows = await tx
.select({
deviceName: ekklesiaCoreDevicePairingRequest.deviceName,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
id: ekklesiaCoreDevicePairingRequest.id,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
state: ekklesiaCoreDevicePairingRequest.state,
approvedByUserId: ekklesiaCoreDevicePairingRequest.approvedByUserId,
})
.from(ekklesiaCoreDevicePairingRequest)
.where(eq(ekklesiaCoreDevicePairingRequest.claimSecretHash, claimSecretHash))
.limit(1);
const request = rows[0];
if (!request) {
return { status: "unknown" as const };
}
if (request.state === "consumed") {
// A credential was already issued for this request. Replaying the claim never issues a
// second one.
return { status: "unknown" as const };
}
if (request.expiresAt.getTime() <= input.now.getTime()) {
return { status: "expired" as const };
}
if (request.state !== "approved") {
return { status: "pending" as const };
}
// The single-use gate: only the transaction whose UPDATE actually matches the still
// `approved` row proceeds to mint a credential. A concurrent second claim matches zero
// rows and is told the request is gone.
const consumed = await tx
.update(ekklesiaCoreDevicePairingRequest)
.set({ consumedAt: input.now, state: "consumed" })
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.id, request.id),
eq(ekklesiaCoreDevicePairingRequest.state, "approved"),
),
)
.returning({ id: ekklesiaCoreDevicePairingRequest.id });
if (consumed.length === 0) {
return { status: "unknown" as const };
}
const credential = generateSecret();
const created = await tx
.insert(ekklesiaCoreDevice)
.values({
createdByUserId: request.approvedByUserId,
credentialHash: hashDeviceSecret(credential),
name: request.deviceName ?? "Display device",
})
.returning({ id: ekklesiaCoreDevice.id });
const deviceId = created[0]?.id;
if (deviceId === undefined) {
throw new DeviceStoreError("DEVICE_STORE_FAILED");
}
// Exactly the one permission the request was created for — never a bundle, never every
// permission the module declares.
await tx.insert(ekklesiaCoreDevicePermission).values({
deviceId,
grantedByUserId: request.approvedByUserId,
permissionId: request.permissionId,
});
await tx
.update(ekklesiaCoreDevicePairingRequest)
.set({ deviceId })
.where(eq(ekklesiaCoreDevicePairingRequest.id, request.id));
return {
paired: Object.freeze({
credential,
deviceId: deviceId as EkklesiaDeviceId,
expiresAt: new Date(input.now.getTime() + DEVICE_CREDENTIAL_TTL_MS),
}),
status: "pending" as const,
};
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async createPairingRequest(input): Promise<CreatedPairingRequest> {
await sweepDeadRequests(input.now);
const live = await db
.select({ id: ekklesiaCoreDevicePairingRequest.id })
.from(ekklesiaCoreDevicePairingRequest)
.where(
and(
gt(ekklesiaCoreDevicePairingRequest.expiresAt, input.now),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
),
)
.limit(MAX_LIVE_PAIRING_REQUESTS);
if (live.length >= MAX_LIVE_PAIRING_REQUESTS) {
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
}
const claimSecret = generateSecret();
const expiresAt = new Date(input.now.getTime() + DEVICE_PAIRING_REQUEST_TTL_MS);
// Collisions are resolved by the partial unique index, not by reading first and hoping: a
// concurrent request that took the same code makes this insert fail and we simply draw again.
for (let attempt = 0; attempt < PAIRING_CODE_ALLOCATION_ATTEMPTS; attempt += 1) {
const code = generatePairingCode();
try {
await db.insert(ekklesiaCoreDevicePairingRequest).values({
claimSecretHash: hashDeviceSecret(claimSecret),
code,
expiresAt,
moduleId: input.moduleId,
permissionId: input.permissionId,
});
return Object.freeze({ claimSecret, code, expiresAt });
} catch {
// Fall through and draw a different code.
}
}
throw new DeviceStoreError("DEVICE_PAIRING_CODE_UNAVAILABLE");
},
async findPendingByCode(code, now): Promise<PairingRequestView | undefined> {
try {
const rows = await db
.select({
createdAt: ekklesiaCoreDevicePairingRequest.createdAt,
expiresAt: ekklesiaCoreDevicePairingRequest.expiresAt,
moduleId: ekklesiaCoreDevicePairingRequest.moduleId,
permissionId: ekklesiaCoreDevicePairingRequest.permissionId,
state: ekklesiaCoreDevicePairingRequest.state,
})
.from(ekklesiaCoreDevicePairingRequest)
.where(
and(
eq(ekklesiaCoreDevicePairingRequest.code, code),
eq(ekklesiaCoreDevicePairingRequest.state, "pending"),
gt(ekklesiaCoreDevicePairingRequest.expiresAt, now),
),
)
.limit(1);
const row = rows[0];
return row === undefined
? undefined
: Object.freeze({
createdAt: row.createdAt,
expiresAt: row.expiresAt,
moduleId: row.moduleId,
permissionId: row.permissionId,
status: "pending" as const,
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async hasActivePermission(deviceId, permission): Promise<boolean> {
try {
// Joined against the device row so a revoked device has no permissions at all, without
// every permission row needing to be revoked separately.
const rows = await db
.select({ id: ekklesiaCoreDevicePermission.id })
.from(ekklesiaCoreDevicePermission)
.innerJoin(
ekklesiaCoreDevice,
eq(ekklesiaCoreDevice.id, ekklesiaCoreDevicePermission.deviceId),
)
.where(
and(
eq(ekklesiaCoreDevicePermission.deviceId, deviceId),
eq(ekklesiaCoreDevicePermission.permissionId, permission),
isNull(ekklesiaCoreDevicePermission.revokedAt),
isNull(ekklesiaCoreDevice.revokedAt),
),
)
.limit(1);
return rows.length > 0;
} catch (error) {
rethrowAsStoreError(error);
}
},
async listDevices(): Promise<readonly DeviceSummary[]> {
try {
const devices = await db
.select({
createdAt: ekklesiaCoreDevice.createdAt,
id: ekklesiaCoreDevice.id,
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
name: ekklesiaCoreDevice.name,
revokedAt: ekklesiaCoreDevice.revokedAt,
})
.from(ekklesiaCoreDevice)
.orderBy(ekklesiaCoreDevice.createdAt);
const permissions = await db
.select({
deviceId: ekklesiaCoreDevicePermission.deviceId,
permissionId: ekklesiaCoreDevicePermission.permissionId,
})
.from(ekklesiaCoreDevicePermission)
.where(isNull(ekklesiaCoreDevicePermission.revokedAt));
return Object.freeze(
devices.map((device) =>
Object.freeze({
createdAt: device.createdAt,
id: device.id as EkklesiaDeviceId,
name: device.name,
permissions: Object.freeze(
permissions
.filter((permission) => permission.deviceId === device.id)
.map((permission) => permission.permissionId)
.sort(),
),
...(device.lastSeenAt === null ? {} : { lastSeenAt: device.lastSeenAt }),
...(device.revokedAt === null ? {} : { revokedAt: device.revokedAt }),
}),
),
);
} catch (error) {
rethrowAsStoreError(error);
}
},
async resolveCredential(credential, now): Promise<ResolvedDevice | undefined> {
try {
const rows = await db
.select({
createdAt: ekklesiaCoreDevice.createdAt,
id: ekklesiaCoreDevice.id,
lastSeenAt: ekklesiaCoreDevice.lastSeenAt,
})
.from(ekklesiaCoreDevice)
.where(
and(
eq(ekklesiaCoreDevice.credentialHash, hashDeviceSecret(credential)),
isNull(ekklesiaCoreDevice.revokedAt),
),
)
.limit(1);
const row = rows[0];
if (!row) {
return undefined;
}
// The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable.
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
return undefined;
}
return Object.freeze({
deviceId: row.id as EkklesiaDeviceId,
...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }),
});
} catch (error) {
rethrowAsStoreError(error);
}
},
async revokeDevice(input): Promise<void> {
try {
const rows = await db
.update(ekklesiaCoreDevice)
.set({ revokedAt: input.now, revokedByUserId: input.revokedBy })
.where(
and(eq(ekklesiaCoreDevice.id, input.deviceId), isNull(ekklesiaCoreDevice.revokedAt)),
)
.returning({ id: ekklesiaCoreDevice.id });
if (rows.length === 0) {
// Either no such device or already revoked. Both are "not an active device", and telling
// the two apart would only serve enumeration.
const existing = await db
.select({ id: ekklesiaCoreDevice.id })
.from(ekklesiaCoreDevice)
.where(eq(ekklesiaCoreDevice.id, input.deviceId))
.limit(1);
if (existing.length === 0) {
throw new DeviceStoreError("DEVICE_NOT_FOUND");
}
return;
}
await db
.update(ekklesiaCoreDevicePermission)
.set({ revokedAt: input.now })
.where(
and(
eq(ekklesiaCoreDevicePermission.deviceId, input.deviceId),
isNull(ekklesiaCoreDevicePermission.revokedAt),
),
);
} catch (error) {
rethrowAsStoreError(error);
}
},
async touchLastSeen(deviceId, now): Promise<void> {
// Throttled in SQL so concurrent requests from one display cannot each decide to write.
await db
.update(ekklesiaCoreDevice)
.set({ lastSeenAt: now })
.where(
and(
eq(ekklesiaCoreDevice.id, deviceId),
or(
isNull(ekklesiaCoreDevice.lastSeenAt),
lt(
ekklesiaCoreDevice.lastSeenAt,
new Date(now.getTime() - DEVICE_LAST_SEEN_THROTTLE_MS),
),
),
),
)
.catch(() => undefined);
},
};
return Object.freeze(store);
}
/** Exported for the schema-drift check and tests; never used to build a query string by hand. */
export const DEVICE_PAIRING_CODE_PATTERN = /^[0-9]{6}$/u;
export { sql as deviceStoreSql };
+22 -2
View File
@@ -11,13 +11,33 @@ function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSecti
const sections: readonly DoctorSection[] = Object.freeze([ const sections: readonly DoctorSection[] = Object.freeze([
section("Platform", ["platform.runtime"]), section("Platform", ["platform.runtime"]),
section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), section("Modules", [
"modules.discovery",
"modules.dependencies",
"modules.permissions",
"modules.lifecycle",
"modules.configuration",
]),
section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]), section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration", "identity.client-address"]), section("Identity", ["identity.configuration", "identity.client-address"]),
section("Object storage", ["object-storage.integration"]), section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
section("Outbound email", ["outbound-email.configuration"]),
]); ]);
/**
* Every check id, exactly once.
*
* A check absent from `sections` is still computed and still counted toward the overall result, but
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
* cannot be added without being rendered.
*/
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
sections.flatMap((entry) => [...entry.checkIds]),
);
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({ const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
failure: "✗", failure: "✗",
pass: "✓", pass: "✓",
+604 -10
View File
@@ -1,23 +1,29 @@
import path from "node:path"; import path from "node:path";
import { import {
capabilityIdSchema,
EMPTY_MODULE_SECRET_SOURCE,
isVersionCompatible, isVersionCompatible,
logicalBucketIdSchema,
type ModuleSecretSource,
moduleConfigurationKeySchema,
moduleIdSchema, moduleIdSchema,
permissionIdSchema, permissionIdSchema,
semanticVersionSchema, semanticVersionSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm"; import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
import type { PostgresPoolOptions } from "../database/pool.js";
import { import {
assertProductionDatabaseTransport, assertProductionDatabaseTransport,
type DatabaseTransportEnvironment,
type DatabaseTransportPolicyInput,
DatabaseTransportError,
createDatabaseTransportPolicy, createDatabaseTransportPolicy,
type DatabaseTransportEnvironment,
DatabaseTransportError,
type DatabaseTransportPolicyInput,
describeDatabaseTransport, describeDatabaseTransport,
} from "../database/transport.js"; } from "../database/transport.js";
import { import {
type ClientAddressConfigInput,
ClientAddressConfigError, ClientAddressConfigError,
type ClientAddressConfigInput,
createClientAddressConfig, createClientAddressConfig,
} from "../identity/client-address.js"; } from "../identity/client-address.js";
import { import {
@@ -27,6 +33,14 @@ import {
} from "../identity/config.js"; } from "../identity/config.js";
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js"; import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
import { identityAdapterSchema } from "../identity/schema.js"; import { identityAdapterSchema } from "../identity/schema.js";
import {
createMailProviderConfig,
createSmtpMailProvider,
describeMailProvider,
MailConfigError,
type MailProviderConfig,
type MailProviderConfigInput,
} from "../mail/index.js";
import { import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
discoverAllMigrations, discoverAllMigrations,
@@ -39,14 +53,34 @@ import {
} from "../migrations/index.js"; } from "../migrations/index.js";
import { import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
createModuleConfigurationDatabase,
createModuleConfigurationStore,
createModuleLifecycleDatabase,
createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModuleSchemas, discoverModuleSchemas,
discoverModules, discoverModules,
ModulePermissionCompositionError, isConfigurationBlockedDetail,
isStorageBlockedDetail,
type ModuleConfigurationSettings,
type ModuleDiscoveryOptions, type ModuleDiscoveryOptions,
type ModuleEligibility,
type ModuleLifecycleConfiguration,
type ModuleMigrationAggregate, type ModuleMigrationAggregate,
ModulePermissionCompositionError,
type ModuleSchemaAggregate, type ModuleSchemaAggregate,
resolveModuleEligibility,
} from "../modules/index.js"; } from "../modules/index.js";
import {
createS3StorageProvider,
createStorageProviderConfig,
describeStorageProvider,
physicalBucketName,
StorageConfigError,
type StorageProviderConfig,
type StorageProviderConfigInput,
} from "../storage/index.js";
export type DoctorCheckId = export type DoctorCheckId =
| "database.transport" | "database.transport"
@@ -54,13 +88,17 @@ export type DoctorCheckId =
| "modules.discovery" | "modules.discovery"
| "modules.dependencies" | "modules.dependencies"
| "modules.permissions" | "modules.permissions"
| "modules.lifecycle"
| "modules.configuration"
| "schemas.discovery" | "schemas.discovery"
| "migrations.discovery" | "migrations.discovery"
| "postgresql.connection" | "postgresql.connection"
| "postgresql.plan" | "postgresql.plan"
| "identity.configuration" | "identity.configuration"
| "identity.client-address" | "identity.client-address"
| "object-storage.integration"; | "object-storage.integration"
| "object-storage.bindings"
| "outbound-email.configuration";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning"; export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings"; export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
@@ -79,6 +117,7 @@ export interface DoctorReport {
export interface DoctorIdentityConfigurationOptions { export interface DoctorIdentityConfigurationOptions {
readonly baseUrl?: string | undefined; readonly baseUrl?: string | undefined;
readonly dashboardOrigin?: string | undefined;
readonly environment?: CreateIdentityConfigOptions["environment"]; readonly environment?: CreateIdentityConfigOptions["environment"];
readonly publicSignupEnabled?: string | undefined; readonly publicSignupEnabled?: string | undefined;
readonly secret?: string | undefined; readonly secret?: string | undefined;
@@ -94,6 +133,30 @@ export interface DoctorOptions {
readonly databaseUrl?: string | undefined; readonly databaseUrl?: string | undefined;
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
readonly moduleDiscovery: ModuleDiscoveryOptions; readonly moduleDiscovery: ModuleDiscoveryOptions;
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports every
* required secret as missing, which is only the honest answer for a deployment that has actually
* set none, so an executable must pass the same source it would give the runtime. */
readonly moduleSecrets?: ModuleSecretSource;
/**
* The host's object-storage settings as the executable read them, validated here.
*
* **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider
* is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that
* validates it and reports the sanitized code. A pre-validated option meant a bad value threw
* before a report existed, and — because the executable then had to validate it itself — meant
* `pnpm doctor` simply never passed one at all, reporting configured storage as absent.
*/
readonly storageConfiguration?: StorageProviderConfigInput | undefined;
/**
* The host's outbound mail settings as the executable read them, validated here.
*
* Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a
* malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to
* be the one that validates it and reports the sanitized code. A pre-validated option would mean
* a bad value crashed the command instead of appearing in its report.
*/
readonly mailConfiguration?: MailProviderConfigInput | undefined;
readonly runtimeRange: string; readonly runtimeRange: string;
readonly runtimeVersion: string; readonly runtimeVersion: string;
} }
@@ -113,11 +176,30 @@ export interface DoctorDependencies {
options: ModuleDiscoveryOptions, options: ModuleDiscoveryOptions,
) => Promise<readonly DiscoveredModule[]>; ) => Promise<readonly DiscoveredModule[]>;
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>; readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
/** Reads the stored module enablement configuration read-only. Separated behind the dependency
* boundary like every other I/O Doctor performs, so the whole report stays exercisable without a
* database. */
readonly readModuleLifecycle: (
connectionString: string,
options: PostgresPoolOptions,
) => Promise<ModuleLifecycleConfiguration>;
/** Reads the stored module configuration read-only, behind the same dependency boundary as every
* other I/O Doctor performs. */
readonly readModuleConfiguration: (
connectionString: string,
options: PostgresPoolOptions,
) => Promise<ModuleConfigurationSettings>;
readonly planMigrations: (options: { readonly planMigrations: (options: {
readonly aggregate: ModuleMigrationAggregate; readonly aggregate: ModuleMigrationAggregate;
readonly database: MigrationDatabase; readonly database: MigrationDatabase;
}) => Promise<MigrationPlanResult>; }) => Promise<MigrationPlanResult>;
readonly runtimeSatisfies: (version: string, range: string) => boolean; readonly runtimeSatisfies: (version: string, range: string) => boolean;
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
/** Opens an SMTP connection, authenticates when credentials are configured, and closes —
* **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */
readonly probeMail: (config: MailProviderConfig) => Promise<void>;
} }
const doctorPostgresTimeoutMs = 10_000; const doctorPostgresTimeoutMs = 10_000;
@@ -134,6 +216,60 @@ async function discoverAllMigrationsForRuntime(
}); });
} }
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
async function readModuleLifecycleForRuntime(
connectionString: string,
options: PostgresPoolOptions,
): Promise<ModuleLifecycleConfiguration> {
const { close, db } = createModuleLifecycleDatabase(connectionString, options);
try {
return await createModuleLifecycleStore(db).readConfiguration();
} finally {
await close().catch(() => undefined);
}
}
/** Opens its own short-lived connection, reads, and always closes it — the same discipline
* `readModuleLifecycleForRuntime` follows. */
async function readModuleConfigurationForRuntime(
connectionString: string,
options: PostgresPoolOptions,
): Promise<ModuleConfigurationSettings> {
const { close, db } = createModuleConfigurationDatabase(connectionString, options);
try {
return await createModuleConfigurationStore(db).readSettings();
} finally {
await close().catch(() => undefined);
}
}
/** Opens a provider client, probes, and always closes it — Doctor never leaves a connection behind. */
async function probeStorageForRuntime(config: StorageProviderConfig): Promise<void> {
const provider = createS3StorageProvider(config);
try {
await provider.probe();
} finally {
await provider.close().catch(() => undefined);
}
}
/**
* Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind.
*
* `verify()` performs the SMTP handshake and, where credentials are configured, an authentication
* exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every
* time an operator ran it would be its own incident.
*/
async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const provider = createSmtpMailProvider(config);
try {
await provider.verify();
} finally {
await provider.close().catch(() => undefined);
}
}
const productionDependencies: DoctorDependencies = Object.freeze({ const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry, composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase, createMigrationDatabase: createPostgresMigrationDatabase,
@@ -141,6 +277,10 @@ const productionDependencies: DoctorDependencies = Object.freeze({
discoverModules, discoverModules,
discoverSchemas: discoverModuleSchemas, discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations, planMigrations: planModuleMigrations,
probeMail: probeMailForRuntime,
probeStorage: probeStorageForRuntime,
readModuleConfiguration: readModuleConfigurationForRuntime,
readModuleLifecycle: readModuleLifecycleForRuntime,
runtimeSatisfies: isVersionCompatible, runtimeSatisfies: isVersionCompatible,
}); });
@@ -177,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) }); return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
} }
/**
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
* — how many of them have a compatible provider among the installed set.
*
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
* capability nobody provides, or whose provider moved outside its declared range, is simply not
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
* are derived from the same publication registry the API serves from, so this can never disagree
* with what the running platform would actually publish.
*
* Static only, like every other Doctor check: it reports what the *manifests* support and never
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
*/
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] { function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
const publication = createModuleUiPublicationRegistry(modules);
const compatible = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
return modules.map((module) => { return modules.map((module) => {
const id = module.manifest.id; const id = module.manifest.id;
const version = module.manifest.version; const version = module.manifest.version;
@@ -187,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
) { ) {
throw new TypeError("Unsafe module diagnostic value."); throw new TypeError("Unsafe module diagnostic value.");
} }
return `${id} ${version}`; const declared = module.contributions.length;
if (declared === 0) {
return `${id} ${version}`;
}
const resolved = module.contributions.filter((contribution) =>
compatible.has(`${id}\u0000${contribution.id}`),
).length;
return `${id} ${version} · ${declared} browser contribution${
declared === 1 ? "" : "s"
} (${resolved} with a compatible provider)`;
}); });
} }
@@ -249,6 +417,222 @@ function permissionCompositionFailure(error: unknown): DoctorCheck {
]); ]);
} }
/**
* Renders one module's operational state in a form that is safe by construction: only a module id, a
* semantic version, a fixed state word, a fixed reason code from a closed set, and — for a
* capability edge — a capability id. Identity values are re-validated before interpolation, exactly
* like `safeModuleDetails` and `safeMigrationReference` do, so a future drift bug cannot turn this
* into a disclosure channel. No filesystem path, package name, manifest path, or caught error can
* reach this line, because none of them is on the record it reads.
*/
function safeLifecycleDetail(entry: ModuleEligibility): string {
if (
!moduleIdSchema.safeParse(entry.moduleId).success ||
!semanticVersionSchema.safeParse(entry.version).success
) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
const identity = `${entry.moduleId} ${entry.version}`;
if (!entry.configured.enabled) {
return `${identity} · disabled${entry.configured.explicit ? "" : " (default)"}`;
}
if (!entry.blocked) {
return `${identity} · enabled`;
}
if (isStorageBlockedDetail(entry.blocked)) {
// A storage block names the **logical** bucket the module declared — never an endpoint, a
// credential, or the physical bucket the host maps it to. Re-validated against the same grammar
// the manifest accepted it under, for the same defense-in-depth reason as every other
// interpolation here.
if (!logicalBucketIdSchema.safeParse(entry.blocked.storageBucketId).success) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.storageBucketId}`;
}
if (isConfigurationBlockedDetail(entry.blocked)) {
// A configuration block names the offending **key** and never its value: a key is declared in a
// public manifest, while a value may be a credential or arbitrary operator-entered text. The key
// is re-validated against the same grammar the manifest accepted it under, for the same
// defense-in-depth reason every other interpolation here is.
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
}
const dependency = entry.blocked.dependency;
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
const target =
dependency.kind === "capability" &&
dependency.capabilityId !== undefined &&
capabilityIdSchema.safeParse(dependency.capabilityId).success
? `${dependency.capabilityId} (${dependency.moduleId})`
: dependency.moduleId;
return `${identity} · blocked · ${entry.blocked.reason} · ${target}`;
}
/**
* Reports what an operator most wants to know before anything else: which installed modules this
* deployment would actually run, and why any of them would not.
*
* Configuration lives in PostgreSQL, so this check is honestly skipped when `DATABASE_URL` is not
* configured rather than reporting a default that might not be what is stored. A **disabled** module
* is a `pass` with a detail line: it is a decision somebody made, not a problem. A **blocked** module
* is a `warning`: an enabled module that cannot run is an anomaly worth surfacing, and it is exactly
* the question "why is this module unavailable?" that would otherwise send an operator to the logs.
*/
function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorCheck {
const disabled = entries.filter((entry) => !entry.configured.enabled).length;
const blocked = entries.filter((entry) => entry.blocked !== undefined).length;
const eligible = entries.filter((entry) => entry.eligible).length;
const details = [
`${eligible} eligible · ${disabled} disabled · ${blocked} blocked`,
...entries.map(safeLifecycleDetail),
];
if (blocked > 0) {
return freezeCheck(
"modules.lifecycle",
"warning",
blocked === 1 ? "1 enabled module cannot run" : `${blocked} enabled modules cannot run`,
details,
);
}
return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details);
}
/**
* Reports whether each installed module's configuration would actually let it run — and
* deliberately reports **nothing about the values themselves**.
*
* Not one configuration value is printed, secret or otherwise. A value is operator-entered text that
* may be an endpoint, a bucket name, or a credential that ended up in the wrong field; a *key* is
* declared in a public manifest and is safe. So a healthy module renders as a count, and an unhealthy
* one names the reason and the offending key. That is everything an operator needs to act, and
* nothing that turns `pnpm doctor` output — which people paste into chat — into a disclosure channel.
*/
function safeConfigurationDetail(entry: ModuleEligibility, declaredCount: number): string {
if (
!moduleIdSchema.safeParse(entry.moduleId).success ||
!semanticVersionSchema.safeParse(entry.version).success
) {
throw new TypeError("Unsafe module configuration diagnostic value.");
}
const identity = `${entry.moduleId} ${entry.version}`;
if (declaredCount === 0) {
return `${identity} · no configuration declared`;
}
const declared = `${declaredCount} ${plural(declaredCount, "key")} declared`;
if (entry.blocked && isConfigurationBlockedDetail(entry.blocked)) {
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
throw new TypeError("Unsafe module configuration diagnostic value.");
}
return `${identity} · ${declared} · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
}
if (!entry.configured.enabled) {
// Not evaluated: a disabled module's configuration is not resolved at all, and claiming it is
// valid would be asserting something nothing checked.
return `${identity} · ${declared} · not evaluated (disabled)`;
}
return `${identity} · ${declared} · valid`;
}
/**
* The configuration counterpart to `modules.lifecycle`: which installed modules could not run
* because of their own configuration, and which key is at fault.
*
* A module whose configuration is fine, or which declares none, is a `pass` line. A module blocked
* by configuration is a `warning`, for the same reason a dependency-blocked module is: an enabled
* module that cannot run is an anomaly an operator should see without reading logs.
*/
function moduleConfigurationCheck(
entries: readonly ModuleEligibility[],
declaredCounts: ReadonlyMap<string, number>,
): DoctorCheck {
const blockedByConfiguration = entries.filter(
(entry) => entry.blocked !== undefined && !("dependency" in entry.blocked),
).length;
const declaringModules = entries.filter(
(entry) => (declaredCounts.get(entry.moduleId) ?? 0) > 0,
).length;
const details = [
`${declaringModules} ${plural(declaringModules, "module")} declaring configuration · ${blockedByConfiguration} blocked`,
...entries.map((entry) =>
safeConfigurationDetail(entry, declaredCounts.get(entry.moduleId) ?? 0),
),
];
if (blockedByConfiguration > 0) {
return freezeCheck(
"modules.configuration",
"warning",
blockedByConfiguration === 1
? "1 module cannot run with its current configuration"
: `${blockedByConfiguration} modules cannot run with their current configuration`,
details,
);
}
return freezeCheck("modules.configuration", "pass", "Module configuration valid", details);
}
/**
* Reports which installed modules require object storage, and where the host keeps each binding.
*
* Names the module, the **logical** bucket it declared, and the physical bucket the host maps it to
* — the last of which is safe and useful here precisely because Doctor is an operator tool: someone
* debugging a permissions problem needs to know which bucket to look at. It is still never reachable
* from module-facing code (ADR 0030). No endpoint, access key, or secret key appears in any form.
*/
function storageBindingsCheck(
modules: readonly DiscoveredModule[],
entries: readonly ModuleEligibility[],
config: StorageProviderConfig | undefined,
): DoctorCheck {
const requiring = modules.filter((module) => module.manifest.storage.buckets.length > 0);
if (requiring.length === 0) {
// An installation whose modules need no object storage is not unhealthy for lacking a provider.
return freezeCheck("object-storage.bindings", "pass", "No module requires object storage", [
"0 modules declare a logical storage bucket.",
]);
}
const blocked = entries.filter(
(entry) => entry.blocked !== undefined && isStorageBlockedDetail(entry.blocked),
);
const details = requiring.flatMap((module) => {
const moduleId = module.manifest.id;
if (!moduleIdSchema.safeParse(moduleId).success) {
throw new TypeError("Unsafe module storage diagnostic value.");
}
return module.manifest.storage.buckets.map((bucket) => {
if (!logicalBucketIdSchema.safeParse(bucket.id).success) {
throw new TypeError("Unsafe module storage diagnostic value.");
}
return config === undefined
? `${moduleId} · ${bucket.id} · unbound (no storage provider configured)`
: `${moduleId} · ${bucket.id} → ${physicalBucketName(config.bucketPrefix, moduleId, bucket.id)}`;
});
});
if (config === undefined) {
return freezeCheck(
"object-storage.bindings",
"failure",
blocked.length === 1
? "1 module requires object storage that is not configured"
: `${blocked.length} modules require object storage that is not configured`,
[
"Configure an S3-compatible Ekklesia storage provider (S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY).",
...details,
],
);
}
return freezeCheck(
"object-storage.bindings",
"pass",
`${details.length} ${plural(details.length, "storage binding")} resolved`,
details,
);
}
function moduleDiscoveryFailure(error: unknown): DoctorCheck { function moduleDiscoveryFailure(error: unknown): DoctorCheck {
const details = const details =
error instanceof Error && error.name === "ModuleDiscoveryError" error instanceof Error && error.name === "ModuleDiscoveryError"
@@ -508,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)
@@ -574,6 +975,74 @@ export async function diagnoseEkklesia(
} }
} }
// Deliberately independent of permission composition, schema, and migration discovery: an
// operator asking "is this module running?" must still get an answer when an unrelated check
// failed. It needs only a successful discovery and a configured database.
let lifecycleCheck: DoctorCheck;
let configurationCheck: DoctorCheck;
// Hoisted so the storage-bindings check below reports the same eligibility this pass resolved,
// rather than resolving a second time and risking a different answer.
let eligibilityEntries: readonly ModuleEligibility[] = [];
const skippedConfiguration = (detail: string): DoctorCheck =>
freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]);
if (!discoveredModules) {
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
"Complete module discovery is required.",
]);
configurationCheck = skippedConfiguration("Complete module discovery is required.");
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
"DATABASE_URL is not configured.",
]);
configurationCheck = skippedConfiguration("DATABASE_URL is not configured.");
} else {
try {
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
environment: options.databaseEnvironment ?? "production",
});
const poolOptions = {
connectionTimeoutMs: doctorPostgresTimeoutMs,
operationTimeoutMs: doctorPostgresTimeoutMs,
transport: lifecycleTransport,
};
// Both axes read and resolved in the one pass the platform itself uses, so Doctor can never
// report a module as runnable that startup would block, or the reverse.
// Sequential for the same reason `startApi` reads them sequentially: a deterministic failure
// beats two tiny queries racing.
const configuration = await dependencies.readModuleLifecycle(
options.databaseUrl,
poolOptions,
);
const settings = await dependencies.readModuleConfiguration(options.databaseUrl, poolOptions);
const resolution = resolveModuleEligibility(
discoveredModules,
configuration,
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
{ providerConfigured: storageConfig !== undefined },
);
eligibilityEntries = resolution.entries;
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
configurationCheck = moduleConfigurationCheck(
resolution.entries,
new Map(
discoveredModules.map(
(module) => [module.manifest.id, module.manifest.configuration.length] as const,
),
),
);
} catch {
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
"The stored module enablement configuration could not be read safely.",
]);
configurationCheck = freezeCheck(
"modules.configuration",
"failure",
"Module configuration unavailable",
["The stored module configuration could not be read safely."],
);
}
}
let schemaCheck: DoctorCheck; let schemaCheck: DoctorCheck;
let migrationCheck: DoctorCheck; let migrationCheck: DoctorCheck;
let migrations: ModuleMigrationAggregate | undefined; let migrations: ModuleMigrationAggregate | undefined;
@@ -659,15 +1128,138 @@ export async function diagnoseEkklesia(
options.databaseEnvironment, options.databaseEnvironment,
); );
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [ // Two separate questions, deliberately. "Is a provider configured and reachable?" is
"Core storage integration has not been implemented yet.", // infrastructure diagnostics; "can every module's declared bucket be bound?" is what decides
]); // whether modules run. Conflating them would let a momentary network blip read as a module
// problem (ADR 0030).
let storageCheck: DoctorCheck;
if (storageConfigurationError !== undefined) {
// A failure, unlike an unreachable provider: this is not a network condition an operator can
// wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is
// genuinely not ready, and saying so is what stops the operator from discovering it at boot.
storageCheck = freezeCheck(
"object-storage.integration",
"failure",
"Object storage configuration invalid",
[
`The object-storage settings were rejected: ${storageConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.",
],
);
} else if (!storageConfig) {
storageCheck = freezeCheck(
"object-storage.integration",
"skipped",
"Object storage not configured",
["No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage."],
);
} else {
try {
await dependencies.probeStorage(storageConfig);
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
describeStorageProvider(storageConfig),
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a
// separate browser-facing origin is by definition something a browser reaches and this
// process may not. Reporting "reachable" without that caveat would let an operator conclude
// direct delivery works when only half of it was tested.
...(storageConfig.deliveryEndpoint === storageConfig.endpoint
? []
: [
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
]),
]);
} catch {
// A warning, never a failure: the provider is configured, and a module that is already
// running keeps running through a transient outage. Reporting this as `not-ready` would make
// the platform look broken because a network hiccup happened while Doctor ran.
storageCheck = freezeCheck(
"object-storage.integration",
"warning",
"Object storage unreachable",
[
describeStorageProvider(storageConfig),
"The configured storage service did not respond. Modules already running are unaffected.",
],
);
}
}
const storageBindings = discoveredModules
? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig)
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
"Complete module discovery is required.",
]);
// Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not
// answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in
// the platform sends email yet, so an installation without SMTP is complete rather than degraded
// (ADR 0037).
let mailCheck: DoctorCheck;
let mailConfig: MailProviderConfig | undefined;
let mailConfigurationError: string | undefined;
try {
mailConfig = createMailProviderConfig(options.mailConfiguration, {
environment: hostEnvironment,
});
} catch (error) {
// The sanitized code only — never the host, the username, or the password.
mailConfigurationError =
error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID";
}
if (mailConfigurationError !== undefined) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"failure",
"Outbound email configuration invalid",
[
`The SMTP settings were rejected: ${mailConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.",
],
);
} else if (!mailConfig) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"skipped",
"Outbound email not configured",
[
"No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.",
],
);
} else {
try {
await dependencies.probeMail(mailConfig);
mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [
describeMailProvider(mailConfig),
// Said plainly: this connected and, where credentials are set, authenticated. It did not
// send anything, so it proves the relay accepts this deployment — not that a message would
// reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization
// policy and cannot be established from here.
"Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.",
]);
} catch {
// A warning, never a failure, exactly as for storage: the configuration is valid and nothing
// in the platform depends on mail yet, so a relay that is briefly unreachable — or one that
// declines a bare connectivity check — must not make the deployment read as broken.
mailCheck = freezeCheck(
"outbound-email.configuration",
"warning",
"Outbound email unreachable",
[
describeMailProvider(mailConfig),
"The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.",
],
);
}
}
return publishReport([ return publishReport([
runtimeCheck, runtimeCheck,
moduleCheck, moduleCheck,
dependencyCheck, dependencyCheck,
permissionsCheck, permissionsCheck,
lifecycleCheck,
configurationCheck,
schemaCheck, schemaCheck,
migrationCheck, migrationCheck,
transportCheck, transportCheck,
@@ -676,5 +1268,7 @@ export async function diagnoseEkklesia(
identityCheck, identityCheck,
clientAddressDoctorCheck, clientAddressDoctorCheck,
storageCheck, storageCheck,
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";
+132
View File
@@ -0,0 +1,132 @@
import { randomBytes } from "node:crypto";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { IdentityDrizzleDatabase } from "./database.js";
import { identityAdapterSchema } from "./schema.js";
/**
* Creates a first credential identity **through Better Auth's own sign-up path**, for the
* operator-invoked initial-owner bootstrap and nothing else.
*
* ## Why this is not `createIdentityRuntime`
*
* The server runtime deliberately refuses to create accounts: `disableSignUp` is derived from
* `IdentityConfig.publicSignupEnabled`, which production cannot enable at all
* (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). That is correct and is not relaxed here — calling
* `auth.api.signUpEmail` on the server's own instance is refused with
* `EMAIL_PASSWORD_SIGN_UP_DISABLED`, which was verified against the pinned release rather than
* assumed. So a fresh production installation has no way to create the one identity it needs.
*
* This is that way, and its safety comes from *where it can be invoked*, not from a weaker policy:
* it is an in-process library call reachable only by an operator who already holds the database
* credentials, it serves no HTTP route, and it is mounted nowhere. Nothing in `apps/api` imports
* it. Public signup remains disabled in every environment.
*
* ## Why the library path rather than writing rows
*
* Hashing, salting, email normalization, the `user`/`account` pair, and the 8–128 password bounds
* are all Better Auth's, applied exactly as they are for a browser sign-up. Hand-writing a user row
* and a password hash would be a second, divergent identity implementation — and the failure mode
* is an account that exists but cannot sign in.
*
* ## Prerequisites this deliberately does not have
*
* No `baseURL`, no trusted origins, and no configured identity secret. All three were tested
* against the pinned release: sign-up succeeds without them, and the resulting account signs in
* through a *separately constructed* instance carrying a different secret — password hashes are
* salted per account and independent of the secret. That matters operationally, because it means an
* operator can create the first owner on a freshly provisioned server before DNS, TLS, or the
* public origin exist. The ephemeral secret below exists only so the instance is explicitly
* configured rather than silently defaulted; this instance never mints a session, so nothing is
* ever signed with it and it is discarded when the process exits.
*/
export type BootstrapIdentityErrorCode =
| "BOOTSTRAP_IDENTITY_CREATION_FAILED"
| "BOOTSTRAP_IDENTITY_EMAIL_INVALID"
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG"
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT";
const bootstrapIdentityMessages: Readonly<Record<BootstrapIdentityErrorCode, string>> =
Object.freeze({
BOOTSTRAP_IDENTITY_CREATION_FAILED: "The initial identity could not be created.",
BOOTSTRAP_IDENTITY_EMAIL_INVALID: "The email address is not a valid address.",
BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT:
"The password is shorter than the identity system allows.",
});
/** Never carries the password, the email, a hash, a Better Auth response body, or a cause. */
export class BootstrapIdentityError extends Error {
readonly code: BootstrapIdentityErrorCode;
constructor(code: BootstrapIdentityErrorCode) {
super(bootstrapIdentityMessages[code]);
this.name = "BootstrapIdentityError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface BootstrapIdentityInput {
readonly email: string;
readonly password: string;
readonly name: string;
}
/** Creates one credential identity. Resolving says only that the identity system reported success —
* never that a row exists, which the caller must confirm for itself (see `createInitialPlatformOwner`). */
export type BootstrapIdentityCreator = (input: BootstrapIdentityInput) => Promise<void>;
/** Better Auth reports failures as an `APIError` carrying a `body.code`. Only the codes that are
* genuinely the operator's to act on are distinguished; everything else collapses into the generic
* creation failure so no library internal, response body, or cause is ever surfaced. */
function translateBootstrapIdentityError(error: unknown): BootstrapIdentityError {
const code = (error as { body?: { code?: unknown } } | null)?.body?.code;
if (code === "PASSWORD_TOO_SHORT") {
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT");
}
if (code === "PASSWORD_TOO_LONG") {
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG");
}
if (code === "VALIDATION_ERROR") {
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_EMAIL_INVALID");
}
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_CREATION_FAILED");
}
export function createBootstrapIdentityCreator(
db: IdentityDrizzleDatabase,
): BootstrapIdentityCreator {
const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema: identityAdapterSchema }),
emailAndPassword: {
// No session is minted here. The operator signs in through the ordinary browser transport
// afterwards, which is also what proves the account really works.
autoSignIn: false,
// Enabled on *this* instance only. See the note above: this object serves no HTTP route.
disableSignUp: false,
enabled: true,
// The identical bounds `createIdentityRuntime` pins, so the CLI can never accept a password
// the browser would later reject, or reject one it would accept.
maxPasswordLength: 128,
minPasswordLength: 8,
},
logger: {
// Better Auth's own log call sites interpolate raw input and exceptions; during account
// creation that input includes a password. Nothing it wants to log is ever emitted.
disabled: true,
},
secret: randomBytes(32).toString("base64url"),
});
return async (input: BootstrapIdentityInput): Promise<void> => {
try {
await auth.api.signUpEmail({
body: { email: input.email, name: input.name, password: input.password },
});
} catch (error) {
throw translateBootstrapIdentityError(error);
}
};
}
+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,
});
} }
+2
View File
@@ -1,4 +1,6 @@
export * from "./bootstrap-identity.js";
export * from "./client-address.js"; export * from "./client-address.js";
export * from "./config.js"; export * from "./config.js";
export * from "./database.js"; export * from "./database.js";
export * from "./notifications.js";
export * from "./runtime.js"; export * from "./runtime.js";
+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);
}
+67
View File
@@ -0,0 +1,67 @@
import { sql } from "drizzle-orm";
import { check, index, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
/**
* Core-owned storage for the ordinary, non-secret configuration an operator has set for an
* installed module.
*
* **Secret material is never stored here, and there is nowhere to put it.** Ekklesia has no
* encryption at rest and no key management, so a `secret` key's value is supplied by the deployment
* environment and resolved into a module's snapshot without ever touching PostgreSQL (ADR 0029).
* The store refuses to write a key a module declared as `secret`; this table has no column, flag, or
* convention that would make a plaintext credential look acceptable.
*
* **One row per (module, key), not one JSON document per module.** A `jsonb` blob would put every
* invariant in application code, make a single key impossible to address, and invite exactly the
* arbitrary-settings-bag this pass exists to avoid — the same reasoning ADR 0027 used to reject a
* generic settings framework in favour of purpose-built tables.
*
* Platform-global, like the Dashboard and lifecycle tables, because Ekklesia has no organization or
* tenant model to scope it to. When one exists, this table gains a scope column.
*/
/**
* One stored configuration value.
*
* `key` is **data, never an SQL identifier**. It is a value in a parameterized column, so a module
* declaring an outlandish key can produce a strange row and nothing else — no column, no table, and
* no dynamic SQL anywhere in this domain.
*
* `value` is `text` holding the unparsed literal. Every declared type has one canonical text form,
* and keeping storage untyped is what makes "a persisted value is never silently trusted" a
* structural property: nothing can read this as a number until `resolveModuleConfigurationFor` has
* validated it against the module's own current declaration.
*
* `module_id` carries **no foreign key**, for the same reason the lifecycle and Dashboard tables do
* not: modules are packages discovered on disk, not rows. A value for a module that is not currently
* deployed is therefore normal and is retained, so redeploying restores an operator's settings
* rather than resetting them.
*/
export const ekklesiaCoreModuleConfiguration = pgTable(
"ekklesia_core_module_configuration",
{
moduleId: text("module_id").notNull(),
key: text("key").notNull(),
value: text("value").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
primaryKey({ columns: [table.moduleId, table.key] }),
index("ekklesia_core_module_configuration_module_idx").on(table.moduleId),
// Defense in depth beneath the application-layer identifier contracts, which never produce
// either: an empty module id or key would be an unaddressable, permanently inert row.
check("ekklesia_core_module_configuration_module_check", sql`length(${table.moduleId}) > 0`),
check("ekklesia_core_module_configuration_key_check", sql`length(${table.key}) > 0`),
// The same 2048-character ceiling the contract and the store enforce, restated where the data
// actually lives, so no caller — including a future one — can store a value the resolver would
// then reject as invalid on every boot.
check("ekklesia_core_module_configuration_value_check", sql`length(${table.value}) <= 2048`),
],
);
/** Explicit map passed to Drizzle, mirroring `authorizationSchema`, `dashboardSchema`, and
* `moduleLifecycleSchema`. */
export const moduleConfigurationSchema = {
moduleConfiguration: ekklesiaCoreModuleConfiguration,
} as const;
+221
View File
@@ -0,0 +1,221 @@
import {
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
moduleConfigurationKeySchema,
moduleIdSchema,
} from "@ekklesia/contracts";
import { asc, eq } from "drizzle-orm";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js";
import type { ModuleConfigurationRecord, ModuleConfigurationSettings } from "./configuration.js";
import {
ekklesiaCoreModuleConfiguration,
moduleConfigurationSchema,
} from "./configuration-schema.js";
export type ModuleConfigurationDrizzleDatabase = NodePgDatabase<typeof moduleConfigurationSchema>;
/** A typed Drizzle view over an externally owned pool, exactly like `createModuleLifecycleView` and
* `createDashboardDrizzleView`: it never constructs, registers an error listener on, or closes a
* pool. */
export function createModuleConfigurationView(pool: Pool): ModuleConfigurationDrizzleDatabase {
return drizzle(pool, { schema: moduleConfigurationSchema });
}
export interface ModuleConfigurationDatabaseHandle {
readonly close: () => Promise<void>;
readonly db: ModuleConfigurationDrizzleDatabase;
}
/** A self-contained handle for a caller that needs only this domain for the length of one command —
* Doctor's read-only configuration check. Composes the shared validated pool factory rather than
* repeating connection-string and timeout validation. `apps/api` shares one pool and uses
* `createModuleConfigurationView` instead. */
export function createModuleConfigurationDatabase(
connectionString: string,
options: PostgresPoolOptions = {},
): ModuleConfigurationDatabaseHandle {
const { close, pool } = createValidatedPostgresPool(connectionString, options);
return Object.freeze({ close, db: createModuleConfigurationView(pool) });
}
export type ModuleConfigurationStoreErrorCode =
| "MODULE_CONFIGURATION_KEY_INVALID"
| "MODULE_CONFIGURATION_MODULE_ID_INVALID"
| "MODULE_CONFIGURATION_READ_FAILED"
| "MODULE_CONFIGURATION_VALUE_INVALID"
| "MODULE_CONFIGURATION_WRITE_FAILED";
const storeErrorMessages: Readonly<Record<ModuleConfigurationStoreErrorCode, string>> =
Object.freeze({
MODULE_CONFIGURATION_KEY_INVALID: "A module configuration key is invalid.",
MODULE_CONFIGURATION_MODULE_ID_INVALID: "A module identifier is invalid.",
MODULE_CONFIGURATION_READ_FAILED: "The module configuration could not be read.",
MODULE_CONFIGURATION_VALUE_INVALID: "A module configuration value is invalid.",
MODULE_CONFIGURATION_WRITE_FAILED: "The module configuration could not be saved.",
});
/**
* Never carries the underlying database error, SQL text, host, connection string, stack — **or the
* offending value**.
*
* That last exclusion is specific to this domain and deliberate: a configuration value can be
* operator-entered text of any kind, and an error message is one of the easiest ways for a value to
* end up in a log. The key is safe to name (it is declared in a public manifest); the value never
* is.
*/
export class ModuleConfigurationStoreError extends Error {
readonly code: ModuleConfigurationStoreErrorCode;
constructor(code: ModuleConfigurationStoreErrorCode) {
super(storeErrorMessages[code]);
this.name = "ModuleConfigurationStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface ReplaceModuleConfigurationInput {
readonly moduleId: string;
/** The complete set of ordinary values for this module. Replaces, rather than merges: a key
* absent here is deleted, so an operator clearing a value does not have to discover a separate
* delete verb, and the stored document always matches what was last submitted. */
readonly values: readonly { readonly key: string; readonly value: string }[];
}
export interface ModuleConfigurationStore {
/** Every stored value, in deterministic (module, key) order. Small by nature — a handful of rows
* per configured module — so it is read whole rather than paginated. */
readSettings(): Promise<ModuleConfigurationSettings>;
/**
* Replaces one module's stored values atomically.
*
* Scoped to a single module id by signature: there is no call shape that writes across modules,
* and no verb that takes a raw table, column, or predicate. Writing configuration never starts,
* stops, or reconfigures anything in the running process — the change is applied by the next
* restart.
*/
replace(input: ReplaceModuleConfigurationInput): Promise<readonly ModuleConfigurationRecord[]>;
}
function assertValidModuleId(value: string): void {
if (!moduleIdSchema.safeParse(value).success) {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_MODULE_ID_INVALID");
}
}
function assertValidKey(value: string): void {
if (!moduleConfigurationKeySchema.safeParse(value).success) {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID");
}
}
function assertValidValue(value: string): void {
if (typeof value !== "string" || value.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_VALUE_INVALID");
}
}
function rethrowAsStoreError(error: unknown, code: ModuleConfigurationStoreErrorCode): never {
if (error instanceof ModuleConfigurationStoreError) {
throw error;
}
throw new ModuleConfigurationStoreError(code);
}
/**
* Core's persistence for ordinary module configuration.
*
* Validation lives here rather than only at the HTTP boundary, so the invariants hold for every
* caller: identifiers are checked against the platform's one module-id grammar and one
* configuration-key grammar rather than second copies invented here, and values are bounded exactly
* as the contract and the database bound them.
*
* Nothing in this file reads a manifest, resolves a declaration, applies a default, or decides
* whether a value is *meaningful* — it stores text under a validated key.
* `resolveModuleConfigurationFor` is the one place a value is interpreted, which is what keeps the
* store from acquiring a second, drifting copy of the type rules. It equally never sees a secret:
* the HTTP boundary rejects a `secret` key before a write reaches here, and secret material has no
* storage path at all.
*/
export function createModuleConfigurationStore(
db: ModuleConfigurationDrizzleDatabase,
): ModuleConfigurationStore {
return Object.freeze({
async readSettings(): Promise<ModuleConfigurationSettings> {
try {
const rows = await db
.select({
key: ekklesiaCoreModuleConfiguration.key,
moduleId: ekklesiaCoreModuleConfiguration.moduleId,
value: ekklesiaCoreModuleConfiguration.value,
})
.from(ekklesiaCoreModuleConfiguration)
.orderBy(
asc(ekklesiaCoreModuleConfiguration.moduleId),
asc(ekklesiaCoreModuleConfiguration.key),
);
return Object.freeze({
records: Object.freeze(rows.map((row) => Object.freeze({ ...row }))),
});
} catch (error) {
rethrowAsStoreError(error, "MODULE_CONFIGURATION_READ_FAILED");
}
},
async replace(
input: ReplaceModuleConfigurationInput,
): Promise<readonly ModuleConfigurationRecord[]> {
assertValidModuleId(input.moduleId);
const seen = new Set<string>();
for (const entry of input.values) {
assertValidKey(entry.key);
assertValidValue(entry.value);
if (seen.has(entry.key)) {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID");
}
seen.add(entry.key);
}
try {
// One transaction, so a replace is never observed half-applied: a concurrent boot either
// sees the previous complete document or the new one, never a mixture that would validate
// differently from both.
return await db.transaction(async (tx) => {
// Delete this module's rows, then insert the submitted ones. Expressed as "delete all,
// insert submitted" rather than a key-wise diff: the whole document is being replaced, so
// the outcome is identical and there is one predicate to audit instead of two. The
// predicate is scoped to `input.moduleId`, so another module's rows are unreachable from
// here by construction, not by care.
await tx
.delete(ekklesiaCoreModuleConfiguration)
.where(eq(ekklesiaCoreModuleConfiguration.moduleId, input.moduleId));
if (input.values.length === 0) {
return Object.freeze([]);
}
const inserted = await tx
.insert(ekklesiaCoreModuleConfiguration)
.values(
input.values.map((entry) => ({
key: entry.key,
moduleId: input.moduleId,
value: entry.value,
})),
)
.returning({
key: ekklesiaCoreModuleConfiguration.key,
moduleId: ekklesiaCoreModuleConfiguration.moduleId,
value: ekklesiaCoreModuleConfiguration.value,
});
return Object.freeze(
[...inserted]
.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0))
.map((row) => Object.freeze({ ...row })),
);
});
} catch (error) {
rethrowAsStoreError(error, "MODULE_CONFIGURATION_WRITE_FAILED");
}
},
});
}
+316
View File
@@ -0,0 +1,316 @@
import {
EMPTY_MODULE_SECRET_SOURCE,
isSupportedConfigurationUrl,
MODULE_CONFIGURATION_VALUE_MAX_LENGTH,
type ModuleConfiguration,
type ModuleConfigurationBlockedReason,
type ModuleConfigurationValue,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import type { ReadonlyModuleManifest } from "./discovery.js";
/**
* One declaration exactly as discovery hands it over: deeply frozen, so an `enum`'s `values` arrive
* as a `readonly string[]`. Derived from the manifest type rather than restated, so a change to the
* declaration contract becomes a compile error here instead of a silently divergent second copy.
*/
type DiscoveredConfigurationDeclaration = ReadonlyModuleManifest["configuration"][number];
/**
* One persisted configuration value, exactly as it is stored: an owning module, a key, and an
* **unparsed text literal**.
*
* The value is text rather than a typed column or a JSON document on purpose. Every declared type
* has one canonical text form, values are small and bounded, and — most importantly — keeping the
* stored form untyped makes "a persisted value is never silently trusted" structural rather than
* aspirational: nothing can read this as a number until it has been validated against the module's
* own current declaration.
*/
export interface ModuleConfigurationRecord {
readonly moduleId: string;
readonly key: string;
readonly value: string;
}
/** The complete stored configuration for every module anyone has ever configured. */
export interface ModuleConfigurationSettings {
readonly records: readonly ModuleConfigurationRecord[];
}
/** Everything resolution needs that Core cannot derive from a manifest: what an operator stored,
* and what the deployment supplies for secrets. */
export interface ModuleConfigurationInput {
readonly settings: ModuleConfigurationSettings;
/** Defaults to supplying nothing, so a caller that provides no secrets says so by omission and a
* module requiring one is reported `secret_missing` rather than silently starting without it. */
readonly secrets?: ModuleSecretSource;
}
/** Why one module's configuration could not be resolved, naming the offending key. Never the value:
* a key is public (it is declared in an installed manifest), a value may be a credential. */
export interface ModuleConfigurationIssue {
readonly reason: ModuleConfigurationBlockedReason;
readonly key: string;
}
export interface ResolvedModuleConfiguration {
/** The frozen snapshot the module receives, present only when every declared requirement was
* satisfied. */
readonly configuration?: ModuleConfiguration;
/** The first unsatisfied requirement, in declaration order, or `undefined` when resolution
* succeeded. Reporting one rather than all of them matches how a blocked dependency is reported:
* an operator fixes the named problem and re-runs, and a list of cascading complaints from one
* missing value would be noise. */
readonly issue?: ModuleConfigurationIssue;
/** Stored keys this module's *current* manifest no longer declares. Retained by the store and
* ignored here — never an error, never passed to the module. Surfaced so an operator can see that
* a downgrade or a renamed key left something behind. */
readonly unknownKeys: readonly string[];
}
/**
* The module-facing snapshot, built over a `Map`.
*
* A `Map` rather than a plain object is deliberate and load-bearing: every lookup key here
* originates in a module's manifest, and indexing a plain object with such a key is the exact shape
* of a prototype-pollution bug. With a `Map` there is no prototype to reach, so `constructor` or
* `__proto__` would be ordinary absent keys even if the contract's own reserved-name check were
* ever removed.
*/
function createModuleConfiguration(
values: ReadonlyMap<string, { readonly type: string; readonly value: ModuleConfigurationValue }>,
): ModuleConfiguration {
const sortedKeys = Object.freeze([...values.keys()].sort());
const typed = (key: string, type: string): ModuleConfigurationValue | undefined => {
const entry = values.get(key);
// Fails closed identically for an undeclared key and a declared key of another type, in the
// same style as `PermissionRegistry.get`: a caller can never distinguish "you did not declare
// this" from "you declared it as something else", and neither one silently returns a value.
return entry?.type === type ? entry.value : undefined;
};
return Object.freeze({
get: (key: string): ModuleConfigurationValue | undefined => values.get(key)?.value,
getBoolean: (key: string): boolean | undefined => {
const value = typed(key, "boolean");
return typeof value === "boolean" ? value : undefined;
},
getEnum: (key: string): string | undefined => {
const value = typed(key, "enum");
return typeof value === "string" ? value : undefined;
},
getInteger: (key: string): number | undefined => {
const value = typed(key, "integer");
return typeof value === "number" ? value : undefined;
},
getSecret: (key: string): string | undefined => {
const value = typed(key, "secret");
return typeof value === "string" ? value : undefined;
},
getString: (key: string): string | undefined => {
const value = typed(key, "string");
return typeof value === "string" ? value : undefined;
},
getUrl: (key: string): string | undefined => {
const value = typed(key, "url");
return typeof value === "string" ? value : undefined;
},
has: (key: string): boolean => values.has(key),
keys: (): readonly string[] => sortedKeys,
});
}
/**
* Parses one stored text literal against its declaration, or reports that it does not satisfy it.
*
* Never coerces, truncates, rounds, or falls back to the default: a stored value that does not
* validate is an operator-visible error, not something to quietly repair. `"7 "`, `"07"`, `"yes"`,
* and `"1"` are all rejected for the types where they are not the canonical form, so exactly one
* spelling of any value is ever accepted and two deployments can never resolve the same stored row
* differently.
*/
function parseStoredValue(
declaration: DiscoveredConfigurationDeclaration,
raw: string,
): ModuleConfigurationValue | undefined {
if (raw.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) {
return undefined;
}
switch (declaration.type) {
case "string": {
if (declaration.minLength !== undefined && raw.length < declaration.minLength) {
return undefined;
}
if (declaration.maxLength !== undefined && raw.length > declaration.maxLength) {
return undefined;
}
return raw;
}
case "integer": {
if (!/^-?(?:0|[1-9]\d*)$/u.test(raw)) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
return undefined;
}
if (declaration.minimum !== undefined && parsed < declaration.minimum) {
return undefined;
}
if (declaration.maximum !== undefined && parsed > declaration.maximum) {
return undefined;
}
return parsed;
}
case "boolean": {
return raw === "true" ? true : raw === "false" ? false : undefined;
}
case "enum": {
return declaration.values.includes(raw) ? raw : undefined;
}
case "url": {
return isSupportedConfigurationUrl(raw) ? raw : undefined;
}
case "secret": {
// Unreachable: a secret is never read from storage, and the store refuses to write one. The
// branch exists so adding a type to the union is a compile error here rather than a silent
// acceptance of persisted secret material.
return undefined;
}
}
}
/**
* Resolves one module's complete configuration from its own manifest declarations, the operator's
* stored values, and the deployment's secrets.
*
* Pure and synchronous: no database, no filesystem, no `process.env`, no clock, and no module code.
* It is handed everything it needs and returns either a frozen snapshot or the first unsatisfied
* requirement, so every rule below is testable without PostgreSQL.
*
* ## Precedence, exactly
*
* 1. A stored value that validates against the current declaration wins.
* 2. A stored value that does **not** validate is `configuration_invalid` — never replaced by the
* default, because silently falling back would run the module on a value nobody chose while the
* operator believes theirs took effect.
* 3. Otherwise the declared `default` applies, if there is one.
* 4. Otherwise a `required` key is `configuration_missing` (or `secret_missing`), and an optional
* key is simply absent.
*
* Secrets never participate in steps 1–3: they are never stored and never defaulted, so a secret is
* whatever the deployment supplies, or missing.
*
* ## Keys the manifest no longer declares
*
* Ignored, and retained by the store. A stored key the current manifest does not declare is exactly
* the situation a module downgrade or a renamed key produces, and deleting the row would destroy an
* operator's value on a temporary rollback — the same reasoning ADR 0027 applied to a Dashboard
* placement for an uninstalled module.
*/
export function resolveModuleConfigurationFor(
manifest: ReadonlyModuleManifest,
input: ModuleConfigurationInput,
): ResolvedModuleConfiguration {
const secrets = input.secrets ?? EMPTY_MODULE_SECRET_SOURCE;
const declarations = manifest.configuration;
const declaredKeys = new Set(declarations.map((declaration) => declaration.key));
const stored = new Map<string, string>();
for (const record of input.settings.records) {
if (record.moduleId === manifest.id) {
stored.set(record.key, record.value);
}
}
const unknownKeys = Object.freeze(
[...stored.keys()].filter((key) => !declaredKeys.has(key)).sort(),
);
const values = new Map<string, { type: string; value: ModuleConfigurationValue }>();
for (const declaration of declarations) {
if (declaration.type === "secret") {
// Scoped by construction: the source is asked for this module's id and a key that module's
// own manifest declares. There is no call shape that reaches another module's secret, and no
// way to ask the host for an arbitrary environment variable.
const supplied = secrets.get(manifest.id, declaration.key);
if (typeof supplied === "string" && supplied.length > 0) {
values.set(declaration.key, { type: "secret", value: supplied });
continue;
}
if (declaration.required) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "secret_missing" as const }),
unknownKeys,
});
}
continue;
}
const raw = stored.get(declaration.key);
if (raw !== undefined) {
const parsed = parseStoredValue(declaration, raw);
if (parsed === undefined) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "configuration_invalid" as const }),
unknownKeys,
});
}
values.set(declaration.key, { type: declaration.type, value: parsed });
continue;
}
if (declaration.default !== undefined) {
values.set(declaration.key, { type: declaration.type, value: declaration.default });
continue;
}
if (declaration.required) {
return Object.freeze({
issue: Object.freeze({ key: declaration.key, reason: "configuration_missing" as const }),
unknownKeys,
});
}
}
return Object.freeze({ configuration: createModuleConfiguration(values), unknownKeys });
}
/**
* The environment variable one module's declared secret is read from, in an Ekklesia deployment.
*
* `EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`, with the module id's kebab-case and the key's
* camelCase each folded to `UPPER_SNAKE`. A double underscore separates the three parts so a single
* underscore inside a converted name can never make two different (module, key) pairs collide.
*
* **A module never sees this name.** It calls `config.getSecret("storageCredential")`; the mapping
* is entirely the host's, which is exactly what lets a standalone host read the same declared secret
* from a YAML file or a secret manager without the module's feature code changing (ADR 0029).
*/
export function moduleSecretEnvironmentVariableName(moduleId: string, key: string): string {
const upperSnake = (value: string): string =>
value
.replace(/-/gu, "_")
.replace(/([a-z0-9])([A-Z])/gu, "$1_$2")
.toUpperCase();
return `EKKLESIA_MODULE_SECRET__${upperSnake(moduleId)}__${upperSnake(key)}`;
}
/**
* A secret source backed by a deployment's environment.
*
* Takes the environment **as an argument** rather than reading `process.env` itself, preserving the
* repository rule that reusable Core code never touches the process environment — only executables
* do (`core/src/cli/database-transport-environment.ts` states the same rule for database
* transport). It is also what makes this trivially testable without mutating global state.
*
* There is deliberately no method that takes an arbitrary variable name. The only reachable values
* are those a module declared as `secret` in its own manifest, looked up under a name this function
* derives — a module cannot read `DATABASE_URL`, another module's secret, or anything else about
* the host.
*/
export function createEnvironmentModuleSecretSource(
environment: Readonly<Record<string, string | undefined>>,
): ModuleSecretSource {
return Object.freeze({
get: (moduleId: string, key: string): string | undefined =>
environment[moduleSecretEnvironmentVariableName(moduleId, key)],
});
}
+189 -22
View File
@@ -5,12 +5,29 @@ import {
isVersionCompatible, isVersionCompatible,
type ModuleCapabilityDeclaration, type ModuleCapabilityDeclaration,
type ModuleManifest, type ModuleManifest,
type PermissionId,
safeParseModuleManifest, safeParseModuleManifest,
semanticVersionSchema, semanticVersionSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
/**
* The only manifest entrypoint that resolves to a directory rather than a compiled file.
*
* Every other entrypoint is an executable build artifact, so the compiled-ESM rule is expressed as
* "not a directory entrypoint" rather than as a list of the entrypoints it covers. An allowlist is
* what let `jobs` and, before it, `ui` be added to the manifest and silently escape this check;
* defaulting a new entrypoint to *stricter* validation is the safer failure mode.
*/
const DIRECTORY_ENTRYPOINTS: ReadonlySet<string> = new Set(["migrations"]);
/** Whether an entrypoint must reference compiled ESM JavaScript (ADR 0006's production loading
* boundary): true for every entrypoint that is a file rather than a directory. */
function isCompiledEntrypoint(entrypointName: string): boolean {
return !DIRECTORY_ENTRYPOINTS.has(entrypointName);
}
export type ModuleDiscoveryIssueCode = export type ModuleDiscoveryIssueCode =
| "DISCOVERY_CONFIGURATION_INVALID" | "DISCOVERY_CONFIGURATION_INVALID"
| "MODULE_CAPABILITY_INCOMPATIBLE" | "MODULE_CAPABILITY_INCOMPATIBLE"
@@ -40,11 +57,34 @@ 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;
readonly migrations?: string; readonly migrations?: string;
readonly jobs?: string; readonly jobs?: string;
/** A module's primary presentation artifact (ADR 0021), resolved and validated exactly like the
* other executable entrypoints. Discovery never imports or evaluates browser artifacts. */
readonly ui?: string;
/** The independently mountable full-viewport artifact and its statically declared access policy.
* `artifactPath` is canonical and package-contained; `permission` already matched one of this
* module's own manifest declarations during manifest parsing. */
readonly display?: {
readonly artifactPath: string;
readonly permission: PermissionId;
};
} }
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types, // Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
@@ -65,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;
@@ -83,6 +126,7 @@ interface ModulePackageData {
} }
interface ModuleCandidate { interface ModuleCandidate {
contributions: readonly ResolvedModuleContribution[];
directory: string; directory: string;
directoryName: string; directoryName: string;
entrypoints: ResolvedModuleEntrypoints; entrypoints: ResolvedModuleEntrypoints;
@@ -230,21 +274,75 @@ 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 ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(([entrypointName, relativeEntrypoint]) => ({
entrypointName,
issuePath: `entrypoints.${entrypointName}`,
relativeEntrypoint,
}));
// A contribution artifact is resolved through the identical loop as every other compiled
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
// are one implementation, so a contribution can never quietly receive weaker checks than
// `entrypoints.ui` (ADR 0041).
const configuredContributions = manifest.contributions.map((contribution, index) => ({
entrypointName: `contribution:${contribution.id}`,
issuePath: `contributions.${index}.artifact`,
relativeEntrypoint: contribution.artifact,
}));
const configuredEntrypoints = [
...ordinaryConfiguredEntrypoints,
...(display === undefined
? []
: [
{
entrypointName: "display",
issuePath: "entrypoints.display.artifact",
relativeEntrypoint: display.artifact,
},
]),
...configuredContributions,
];
let entrypointCollision = false;
for (const [entrypointName, relativeEntrypoint] of Object.entries(manifest.entrypoints)) { if (display !== undefined) {
if (relativeEntrypoint === undefined) { const configuredDisplayPath = path.resolve(moduleDirectory, display.artifact);
continue; for (const ordinary of ordinaryConfiguredEntrypoints) {
if (path.resolve(moduleDirectory, ordinary.relativeEntrypoint) === configuredDisplayPath) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `display must not resolve to the same configured file as ${ordinary.entrypointName}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "entrypoints.display.artifact",
});
}
} }
}
for (const {
entrypointName,
issuePath: entrypointIssuePath,
relativeEntrypoint,
} of configuredEntrypoints) {
if ( if (
["schema", "server"].includes(entrypointName) && isCompiledEntrypoint(entrypointName) &&
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase()) ![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
) { ) {
issues.push({ issues.push({
@@ -252,7 +350,7 @@ async function resolveEntrypoints(
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`, message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
continue; continue;
} }
@@ -263,7 +361,7 @@ async function resolveEntrypoints(
message: `${entrypointName} resolves outside its module directory.`, message: `${entrypointName} resolves outside its module directory.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
continue; continue;
} }
@@ -276,27 +374,34 @@ async function resolveEntrypoints(
message: `${entrypointName} resolves through a link outside its module directory.`, message: `${entrypointName} resolves through a link outside its module directory.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
continue; continue;
} }
const entrypointStats = await stat(canonicalEntrypoint); const entrypointStats = await stat(canonicalEntrypoint);
const hasExpectedKind = const hasExpectedKind = DIRECTORY_ENTRYPOINTS.has(entrypointName)
entrypointName === "migrations" ? entrypointStats.isDirectory() : entrypointStats.isFile(); ? entrypointStats.isDirectory()
: entrypointStats.isFile();
if (!hasExpectedKind) { if (!hasExpectedKind) {
issues.push({ issues.push({
code: "MODULE_ENTRYPOINT_INVALID", code: "MODULE_ENTRYPOINT_INVALID",
message: message: DIRECTORY_ENTRYPOINTS.has(entrypointName)
entrypointName === "migrations" ? `${entrypointName} must resolve to a directory.`
? "migrations must resolve to a directory." : `${entrypointName} must resolve to a file.`,
: `${entrypointName} must resolve to a file.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
continue; continue;
} }
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint; if (entrypointName.startsWith("contribution:")) {
resolvedContributions.set(
entrypointName.slice("contribution:".length),
canonicalEntrypoint,
);
} else {
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
}
} catch (error) { } catch (error) {
const code = errorCode(error); const code = errorCode(error);
issues.push({ issues.push({
@@ -309,21 +414,82 @@ async function resolveEntrypoints(
}.`, }.`,
moduleDirectory: directoryName, moduleDirectory: directoryName,
moduleId: manifest.id, moduleId: manifest.id,
path: `entrypoints.${entrypointName}`, path: entrypointIssuePath,
}); });
} }
} }
if (!resolved.server) { if (resolved.display !== undefined) {
for (const [entrypointName, canonicalEntrypoint] of Object.entries(resolved)) {
if (entrypointName !== "display" && canonicalEntrypoint === resolved.display) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `display must not resolve through a link to the same file as ${entrypointName}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "entrypoints.display.artifact",
});
}
}
}
// 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 } : {}),
...(resolved.jobs ? { jobs: resolved.jobs } : {}), ...(resolved.jobs ? { jobs: resolved.jobs } : {}),
...(resolved.ui ? { ui: resolved.ui } : {}),
...(resolved.display && display
? {
display: {
artifactPath: resolved.display,
permission: display.permission,
},
}
: {}),
}; };
return { contributions: Object.freeze(contributions), entrypoints };
} }
async function scanModuleDirectory( async function scanModuleDirectory(
@@ -421,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,
+8
View File
@@ -1,6 +1,14 @@
export * from "./configuration.js";
export * from "./configuration-schema.js";
export * from "./configuration-store.js";
export * from "./database.js"; export * from "./database.js";
export * from "./discovery.js"; export * from "./discovery.js";
export * from "./lifecycle.js";
export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js"; export * from "./migration-discovery.js";
export * from "./permissions.js"; export * from "./permissions.js";
export * from "./realtime.js";
export * from "./runtime.js"; export * from "./runtime.js";
export * from "./schema-discovery.js"; export * from "./schema-discovery.js";
export * from "./ui-publication.js";
+60
View File
@@ -0,0 +1,60 @@
import { sql } from "drizzle-orm";
import { boolean, check, pgTable, text, timestamp } from "drizzle-orm/pg-core";
/**
* Core-owned storage for the one operational decision an administrator makes about an installed
* module: should it run?
*
* This is **configuration, not runtime state**. Nothing about what happened during a boot is stored
* here — not a start failure, not a last-seen timestamp, not a health result. A restart naturally
* retries every enabled module, which is the behavior an operator expects and which persisting a
* transient failure would quietly break (ADR 0028).
*
* It is also **not an installation registry**. A module is "installed" by being a valid module
* package on disk that discovery accepts; this table records nothing about where it came from, how
* it got there, or what version it is. There is deliberately no install source, download URL,
* update channel, signature, marketplace metadata, or free-form JSON column: none of those concepts
* exist in this platform, and a column for one is how an installer gets built by accident.
*
* Platform-global, for the same reason the Dashboard tables are (ADR 0027): Ekklesia has no
* organization or tenant model to scope it to. When one exists, this table gains a scope column.
*
* Table and constraint names are explicit and namespaced exactly like
* `core/src/authorization/schema.ts` and `core/src/dashboard/schema.ts`, so ownership is
* unambiguous inside the shared public PostgreSQL schema.
*/
/**
* One explicit enablement decision.
*
* **A row exists only because somebody decided.** `enabled` has no column default on purpose: the
* platform's default for a newly discovered module is expressed by the *absence* of a row and
* resolved in `resolveModuleEligibility`, not by a default that would silently materialize a
* decision nobody made. Storing `enabled = true` explicitly is still meaningful — it records that
* an administrator looked at this module and left it on.
*
* `module_id` carries **no foreign key to any module**, because modules are packages discovered on
* disk, not rows — the same reasoning `ekklesia_core_dashboard_placement.module_id` already
* follows. A record for a module that is not currently deployed is therefore normal and is
* retained, so redeploying a module restores the decision instead of resetting it.
* `resolveModuleEligibility` ignores such a record entirely.
*/
export const ekklesiaCoreModuleState = pgTable(
"ekklesia_core_module_state",
{
moduleId: text("module_id").primaryKey(),
enabled: boolean("enabled").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
// Defense in depth beneath the application-layer `moduleIdSchema` contract, which never
// produces this: an empty module id would be an unaddressable, permanently inert record.
check("ekklesia_core_module_state_module_check", sql`length(${table.moduleId}) > 0`),
],
);
/** Explicit map passed to Drizzle, mirroring `authorizationSchema` and `dashboardSchema`. */
export const moduleLifecycleSchema = {
moduleState: ekklesiaCoreModuleState,
} as const;

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