827 lines
56 KiB
Markdown
827 lines
56 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.** Phase 6 has since
|
||
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.** 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 module-product template with optional primary and display UI.
|
||
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 6 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 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: 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).
|
||
|
||
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 6 after ADR 0033 added the optional display declaration.
|
||
|
||
**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;
|
||
public/device identity remains deferred. 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.
|
||
|
||
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.
|
||
|
||
**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. Sign-in UI, 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.
|
||
|
||
## 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
|
||
5 permissions registered
|
||
○ Module lifecycle skipped
|
||
DATABASE_URL is not configured.
|
||
○ Module configuration skipped
|
||
DATABASE_URL is not configured.
|
||
|
||
Data definitions
|
||
✓ Schemas validated
|
||
0 modules · 0 tables
|
||
✓ Migrations validated
|
||
6 migrations · checksums valid
|
||
core:0000_identity_core_tables · f8f2086ef06f
|
||
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
|
||
|
||
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.
|
||
✓ Direct client-address mode
|
||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||
|
||
Object storage
|
||
○ Object storage not configured
|
||
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.
|
||
|
||
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 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.
|
||
|
||
`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.
|