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]>
442 lines
31 KiB
Markdown
442 lines
31 KiB
Markdown
# Ekklesia
|
||
|
||
Ekklesia is an open, modular church technology platform. Each module is an independently versioned
|
||
product that owns its domain behavior, data, and its own UI, composed into dashboards, websites,
|
||
mobile apps, kiosks, displays, and other surfaces.
|
||
|
||
## Current status
|
||
|
||
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
|
||
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
|
||
against an independent security audit) is complete: all five passes have landed and the final
|
||
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
||
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** The operational
|
||
requirements that remain are prerequisites for two specific *future* activities — executing
|
||
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
||
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,
|
||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||
schema aggregation, and a cloneable backend-only module template. Migration artifacts have
|
||
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
|
||
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
|
||
hermetic live PostgreSQL suite proves history, rollback, statement breakpoints, advisory-lock
|
||
concurrency and release, exact destructive approvals, and sanitization against the production
|
||
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
|
||
migration-planning services into one concise readiness report.
|
||
|
||
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
|
||
module-owned migrations under the exact same migration system, minimal opaque-identity contracts
|
||
in `@ekklesia/contracts`, and an explicit Better Auth construction boundary in Core. See
|
||
[Identity](docs/development/identity.md) and
|
||
[ADR 0010](docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md).
|
||
|
||
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root
|
||
(`pnpm api:start`) mounts Better Auth at `/api/auth/*` and exposes `GET /api/v1/principal` behind
|
||
Core's own Fetch-compatible identity runtime — Core still does not depend on Hono, and modules
|
||
still cannot reach any of this. Public email/password registration is the intended eventual
|
||
product policy — every account receives identical lowest-privilege baseline status and signup
|
||
order never grants elevation — but is now **disabled by default, and cannot be enabled in
|
||
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
|
||
[API](docs/development/api.md) and
|
||
[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
|
||
production approval-provider composition, jobs, physical storage provisioning, surfaces, and
|
||
feature modules, remains later vertical slices. Public registration is not yet ready for a real
|
||
public launch.
|
||
|
||
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
|
||
immutable permission registry; an explicit, single-use, concurrency-safe platform-owner bootstrap
|
||
command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup
|
||
order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped
|
||
`/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin`
|
||
on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, and service
|
||
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).
|
||
|
||
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
|
||
(version 3 at the time, version 4 today) declares permissions as complete
|
||
`{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
|
||
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
|
||
`/api/v1/grants` HTTP API Core's own permissions already use, with `core.*` reserved and never
|
||
claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission
|
||
composition. Installing, removing, or editing a module currently requires an API restart; removing
|
||
one makes its permission unavailable to new checks and new grants without deleting or revoking any
|
||
historical grant or audit row. See
|
||
[Authorization](docs/development/authorization.md#module-permission-composition),
|
||
[Module development](docs/module-development/README.md), and
|
||
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md).
|
||
|
||
Phase 5B mounts a module's own HTTP routes in production. A module registers framework-neutral
|
||
Fetch (`Request`/`Response`) route handlers during its existing `register` hook; Core collects them
|
||
into an immutable snapshot alongside its capabilities, and `apps/api` discovers modules exactly
|
||
once, starts the module runtime from that same discovered inventory, and mounts its published
|
||
routes under `/api/v1/modules/<module-id>` — after every module has registered and started
|
||
successfully, so no request can reach a module before startup completes. Every route is enforced
|
||
through the identical `requirePermission` middleware and composed permission registry Core's own
|
||
routes use (a module can never supply its own baseline), state-changing methods require a trusted
|
||
`Origin`, and a handler failure or non-`Response` return becomes a fixed, sanitized `500` — never a
|
||
raw error, stack, path, or permission id. Relationship-derived grant creation remains later work.
|
||
See [API](docs/development/api.md#module-http-routes),
|
||
[Module development](docs/module-development/README.md#http-routes), and
|
||
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
|
||
|
||
Phase 5C gives a module's own lifecycle hooks and HTTP handlers typed access to their own
|
||
PostgreSQL tables. `context.services.database` — a restricted, typed `ModuleDatabase` view over
|
||
Drizzle's `select`/`selectDistinct`/`selectDistinctOn`/`insert`/`update`/`delete`/`execute`/
|
||
`transaction`, never a raw `pg` client, pool, or connection string — is available to `start`,
|
||
`stop`, and every authorized route handler, and reuses the one PostgreSQL pool `apps/api` already
|
||
owns; registration remains declarative and database-free. Modules continue to own and import their
|
||
own Drizzle schemas and generated migrations, applied explicitly and never automatically at
|
||
startup. See [API](docs/development/api.md#module-database-access),
|
||
[Module development](docs/module-development/README.md#database-access), and
|
||
[ADR 0016](docs/architecture/decisions/0016-module-database-access.md).
|
||
|
||
Phase 5D pass 1 of 5 hardens the public request perimeter against an independent security audit
|
||
of the Phase 5C checkpoint, without adding a Surface, a product module, or any new capability.
|
||
Better Auth's authentication rate limiter now resolves client identity from a server-owned
|
||
client-address boundary (`core/src/identity/client-address.ts`, wired in at the Node/API transport
|
||
boundary) — direct mode trusts only the real socket peer by default, and an explicit trusted-proxy
|
||
mode is required before any forwarded header is ever consulted, closing a rotating-header
|
||
rate-limit bypass. Public sign-up is now **disabled by default, and a production configuration that
|
||
tries to enable it fails closed** (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, reported by `pnpm
|
||
doctor` as a blocking failure) — the durable abuse-control prerequisites do not exist yet, so only
|
||
development/test composition may enable it. Every public body-consuming route — Better Auth,
|
||
Core's grant-mutation endpoint, and every module route (via a new, required `maxBodyBytes` field on
|
||
the executable route contract) — now has an explicit, platform-enforced byte ceiling with one
|
||
sanitized `413` contract. The Node HTTP server now has finite header/request timeouts, whose real
|
||
transport-level failures are observed and logged in sanitized form. See
|
||
[API](docs/development/api.md), [Module development](docs/module-development/README.md#http-routes),
|
||
and [ADR 0017](docs/architecture/decisions/0017-public-request-perimeter-hardening.md). **This pass
|
||
does not add distributed rate limiting, durable public-signup abuse control, or any deployment/TLS
|
||
guarantee, and it did not by itself complete Phase 5D** — at the close of this pass, passes 2–5
|
||
(identity/database transport hardening, authorization integrity, module/runtime/migration
|
||
correctness, and deployment/supply-chain closure plus a final independent re-audit) were still
|
||
outstanding, and Phase 6 (Surface work) was blocked until they landed.
|
||
|
||
Phase 5D pass 2 of 5 corrects identity session resolution and enforces a database transport policy.
|
||
A request carrying no cookies, or only unrelated cookies, now performs **no** identity database
|
||
work at all; only a request presenting the configured session cookie that Better Auth resolved to
|
||
`null` is disambiguated, by a real read of the session relation and the exact columns the adapter
|
||
depends on (under `where false`, so no row is ever read) rather than the previous `select 1`. A
|
||
missing table, dropped column, revoked privilege, unreachable database, or query timeout therefore
|
||
produces a sanitized `503`, never a silent anonymous downgrade. The identity base URL and every
|
||
trusted origin must now be exactly one canonical origin, and production rejects the full
|
||
loopback/unspecified address space rather than three literal spellings. **Production PostgreSQL
|
||
connections now require TLS with certificate *and* hostname verification** — from the API, both
|
||
migration commands, the bootstrap CLI, and `pnpm doctor` alike — with no encryption-only mode, no
|
||
`rejectUnauthorized: false` escape hatch, no plaintext exception (not even for a loopback
|
||
database), and an explicit refusal of a `DATABASE_URL` that tries to carry its own TLS parameters,
|
||
which the pinned driver would otherwise let silently override the policy. See
|
||
[Identity](docs/development/identity.md), [API](docs/development/api.md#database-transport-tls), and
|
||
[ADR 0018](docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||
**This pass adds no secret store, no certificate rotation, no client-certificate authentication, and
|
||
no distributed rate limiting; TLS protects the connection, not the exposure, so the network boundary
|
||
remains the deployment's own responsibility. This pass did not by itself complete Phase 5D** — at
|
||
its close, passes 3–5 (authorization integrity, module/runtime/migration correctness, and
|
||
deployment/supply-chain closure plus a final independent re-audit) were still outstanding.
|
||
|
||
Phase 5D pass 3 of 5 preserves authorization security history and makes explicit-grant management
|
||
unambiguous and bounded. Authorization **audit history now survives user deletion**: the audit
|
||
subject is a retained identifier snapshot with no cascading foreign key, so deleting a user still
|
||
removes their live grants and platform-owner row but no longer destroys the record of what they had
|
||
been granted and by whom. Platform-owner bootstrap's single use moved to a dedicated singleton with
|
||
no user foreign key — previously deleting the bootstrapped owner's user row silently reopened the
|
||
one operation that establishes a platform's first administrator; the operational consequence is that
|
||
**deleting the last owner does not reactivate bootstrap**, and existing installations are backfilled
|
||
as consumed by the new migration. Two equivalent grants can no longer both be effective: creation
|
||
conflicts with a sanitized `409`, serialized under real PostgreSQL concurrency, and one revocation
|
||
now clears every unrevoked equivalent with a per-row audit entry, so revoking a grant can no longer
|
||
report success while a duplicate keeps authorizing the user. Grant ids must be canonical UUIDs and
|
||
expirations one exact future-only format, both decided before any database call; the generic HTTP
|
||
surface creates unscoped grants only; and listing is bounded keyset pagination with an opaque,
|
||
unsigned position cursor. One forward Core migration (`0002_authorization_integrity`,
|
||
`{"mode":"none"}`) must be applied before the new code starts. See
|
||
[Authorization](docs/development/authorization.md) and
|
||
[ADR 0019](docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
|
||
**This pass adds no roles, teams, tenants, service accounts, scope resolver, user-deletion endpoint,
|
||
or owner-recovery workflow; audit append-only behavior remains an application convention, not a
|
||
database or cryptographic guarantee. This pass did not by itself complete Phase 5D** — at its close,
|
||
passes 4 (module/runtime/migration correctness) and 5 (deployment/supply chain plus a final
|
||
independent re-audit) were still outstanding, and Phase 6 was blocked until they landed.
|
||
|
||
Phase 5D pass 4 of 5 hardens the module runtime and the migration commands. **Two routes of one
|
||
module that could both match the same request path are now rejected at registration**
|
||
(`MODULE_ROUTE_OVERLAP`) instead of being resolved by declaration order — with different permissions
|
||
on `/events/:id` and `/events/new`, registration order alone previously decided which permission
|
||
guarded `/events/new`. Decoded route parameters are documented as untrusted, module-owned input that
|
||
handlers must validate against their own domain grammar; no generic validator or route field was
|
||
added. A module whose `start` hook fails is now rolled back through **its own** `stop` hook first,
|
||
so partially acquired resources are released, and every stop/rollback hook runs under a fixed
|
||
10-second deadline while HTTP shutdown drains for at most 30 seconds before force-closing
|
||
connections through Node's supported public APIs — shutdown is finite at every stage, reports a
|
||
forced drain or a hook failure as an unsuccessful shutdown, and always still reaches pool closure.
|
||
`migrations:plan`/`migrations:apply` now connect with fixed 10-second connection, query, and
|
||
`statement_timeout` defaults that a `DATABASE_URL` cannot weaken (the pinned driver lets a
|
||
connection string override explicit options — verified), and each migration's SQL is sent to
|
||
PostgreSQL **intact, as one parsed batch** inside the existing transaction rather than split on the
|
||
literal `--> statement-breakpoint` bytes, which corrupted any migration containing those bytes in a
|
||
string literal, dollar-quoted body, quoted identifier, or comment. See
|
||
[Module development](docs/module-development/README.md#http-routes),
|
||
[API](docs/development/api.md#graceful-shutdown), and
|
||
[ADR 0020](docs/architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md).
|
||
**Deadlines bound how long the platform waits; they do not cancel or sandbox trusted in-process
|
||
module code, and closing a socket does not prove a handler promise ended. No custom SQL parser
|
||
exists. Modules remain trusted compiled backend code with no process, role, schema, or tenant
|
||
isolation. This pass did not by itself complete Phase 5D** — at its close, pass 5
|
||
(deployment/supply chain plus an independent whole-repository re-audit) was still outstanding, and
|
||
Phase 6 was blocked until it landed.
|
||
|
||
Phase 5D pass 5 of 5 closes the phase with an independent whole-repository re-audit and the
|
||
repository-local half of deployment and supply-chain hardening. The re-audit swept every layer —
|
||
Core, contracts, `apps/api`, scripts, the CI workflow, and documentation — and its repository-local
|
||
findings are resolved here: every dependency advisory is remediated (both `pnpm audit` and
|
||
`pnpm audit --prod` report no known vulnerabilities), every CI action reference is pinned to a full
|
||
immutable commit SHA, installs run `--frozen-lockfile`, no workflow references a repository or
|
||
instance secret, every checkout step sets `persist-credentials: false`, a checksum-verified secret
|
||
scan found no committed credential in the history, and one canonical
|
||
[security document](docs/development/security.md) now states the trusted-module model, the
|
||
undeployed state, and the launch checklist in a single place. **CI is accepted as a
|
||
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
||
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
||
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
||
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
|
||
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
|
||
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
|
||
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||
development proceeds under the current trusted-contributor, not-yet-deployed model. Runner topology
|
||
and job-token authority remain unverified and are not described as isolated. See
|
||
[Security model and deployment trust boundaries](docs/development/security.md).
|
||
|
||
Phase 6A established the **module product contract**. Ekklesia's modules are no
|
||
longer backend-only capability packages: a module is an independently versioned *product* that owns
|
||
its domain behavior, data, permissions, routes, migrations, and — new in this pass — **its own UI**.
|
||
Core keeps the containing application shell and every platform policy (authentication, authorization
|
||
enforcement, platform context, top-level routing, navigation, global theme, error boundaries,
|
||
trusted-origin/CORS). Modules stay **flat peers**: `video`, `livestream`, and `announcements` are
|
||
independent first-class modules with no parent/child relationship, and a Dashboard folder such as
|
||
`Media` is presentation configuration an administrator can reorganize, never module architecture and
|
||
never a dependency. The long-term goal is one module implementation hosted by Ekklesia *and*, later,
|
||
by a standalone application — never two codebases for one domain. See
|
||
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
|
||
|
||
**What that pass shipped is the static contract only.** `manifestVersion: 4` adds 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 stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly
|
||
like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a
|
||
one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was
|
||
before it. **The Dashboard shell, a UI runtime or renderer, navigation
|
||
registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading,
|
||
and every feature module 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 four members — `moduleId`, `api`, `principal`, `permissions` — 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: a stylesheet import **fails 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).
|
||
|
||
**Still no host, and one specific reason why.** Core resolves `entrypoints.ui` to a path on the
|
||
*server's filesystem*; a browser cannot import that. **Ekklesia has no route that serves an installed
|
||
module's artifact at a browser URL**, so nothing can mount an installed module UI yet — the loader is
|
||
complete, asset publication is not, and that is the next pass's first job. The Dashboard shell,
|
||
navigation, a design system, a stylesheet loading contract, and the standalone host 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.
|
||
|
||
## Quick start
|
||
|
||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||
|
||
```bash
|
||
pnpm install --frozen-lockfile
|
||
cp .env.example .env # keep its NODE_ENV=development: see below
|
||
pnpm infrastructure:up
|
||
pnpm modules:validate
|
||
pnpm migrations:validate
|
||
pnpm identity:schema:validate
|
||
pnpm doctor
|
||
pnpm migrations:plan
|
||
pnpm test:migrations:integration
|
||
pnpm verify
|
||
```
|
||
|
||
`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
|
||
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration
|
||
state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
||
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
||
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
|
||
development infrastructure provides is only usable with it set.
|
||
|
||
Doctor never applies migrations, creates a history table, starts module servers, or performs
|
||
storage operations. Schema discovery does import
|
||
compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free.
|
||
|
||
With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped
|
||
like this:
|
||
|
||
```text
|
||
Ekklesia Doctor
|
||
|
||
Platform
|
||
✓ Runtime version supported
|
||
Active Node.js satisfies the declared supported range.
|
||
|
||
Modules
|
||
✓ Module discovery complete
|
||
0 modules installed
|
||
✓ Dependency graph valid
|
||
Dependency order is empty.
|
||
✓ Permission registry composed
|
||
1 permission registered
|
||
|
||
Data definitions
|
||
✓ Schemas validated
|
||
0 modules · 0 tables
|
||
✓ Migrations validated
|
||
3 migrations · checksums valid
|
||
core:0000_identity_core_tables · f8f2086ef06f
|
||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||
core:0002_authorization_integrity · 634294731180
|
||
|
||
PostgreSQL
|
||
✓ Database TLS verified
|
||
TLS required with certificate and hostname verification.
|
||
○ Connection check skipped
|
||
DATABASE_URL is not configured.
|
||
○ Migration plan skipped
|
||
A validated migration inventory and configured PostgreSQL connection are required.
|
||
|
||
Identity
|
||
○ Identity configuration skipped
|
||
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.
|
||
|
||
Object storage
|
||
○ Check unavailable
|
||
Core storage integration has not been implemented yet.
|
||
|
||
Result: READY WITH WARNINGS
|
||
```
|
||
|
||
Checks have stable pass, warning, failure, or skipped states. Warnings and skipped optional checks
|
||
produce `READY WITH WARNINGS` and exit zero; any failure produces `NOT READY` and exit one. The
|
||
dashboard is human-readable local guidance, not a replacement for automated CI evidence.
|
||
|
||
`pnpm migrations:plan` uses `DATABASE_URL` from the uncommitted `.env` and is read-only.
|
||
`pnpm migrations:apply` uses the same validated inventory but mutates PostgreSQL under Core's
|
||
advisory lock and one transaction. Review the plan before applying. Destructive migrations are
|
||
blocked by default and require an exact externally supplied approval; there is no force flag.
|
||
|
||
The runner has both deterministic unit coverage through a narrow database port and a separate live
|
||
PostgreSQL integration suite. The live command creates and destroys disposable Docker
|
||
infrastructure; it remains separate from the Docker-free `pnpm verify` gate.
|
||
|
||
`pnpm identity:schema:validate` regenerates Better Auth's canonical schema with the pinned local
|
||
CLI into a temporary location and structurally compares it against the checked-in
|
||
`core/src/identity/schema.ts`, then removes the temporary output. It requires no `DATABASE_URL`
|
||
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
|
||
workflow.
|
||
|
||
`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
|
||
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
|
||
required configuration, routes, and a full request/response transcript.
|
||
|
||
Run `pnpm infrastructure:smoke` for an isolated end-to-end PostgreSQL and authenticated S3 check.
|
||
|
||
The development services bind only to loopback:
|
||
|
||
- PostgreSQL: `127.0.0.1:5432`
|
||
- S3-compatible object storage: `127.0.0.1:8333`
|
||
|
||
The values in `.env.example` are development-only and must never be reused in a deployed
|
||
environment.
|
||
|
||
## Architecture
|
||
|
||
- [Platform architecture](docs/architecture/platform-architecture.md)
|
||
- [Repository boundaries](docs/architecture/repository-boundaries.md)
|
||
- [Repository workflow](docs/development/repository-workflow.md)
|
||
- [Testing](docs/development/testing.md)
|
||
- [Identity](docs/development/identity.md)
|
||
- [API](docs/development/api.md)
|
||
- [Authorization](docs/development/authorization.md)
|
||
- [Module development](docs/module-development/README.md)
|
||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||
|
||
No open-source license has been selected yet. A license should be added only after the project
|
||
owner makes that decision.
|