CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Failing after 16s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Produces the exact public package tarballs of a platform release, validates them, and proves a standalone module consumes those artifacts end to end. Nothing is published and no credential is configured. - scripts/package-release.ts (pnpm packages:release) is the canonical release builder: from a clean commit it rebuilds the nine public packages from clean output, packs each, inspects every tarball, checks the release set and coordinated versions, and only then writes ekklesia-packages-<version>/ with the tarballs, SHA256SUMS, and a provider-neutral manifest (ekklesia-package-train.json: version, revision, and each package's file, size, SHA-256, npm integrity). - Packing uses a pack-only pnpm beforePacking hook (passed with --config.pnpmfile, never the workspace's install settings). pnpm inserts rewritten workspace: dependencies in async completion order, so two checkouts of one commit packed module-pack's package.json with keys in a different order; the hook restores the source key order (and the inspection enforces it). It also drops devDependencies, which named private packages outside the train. - scripts/test-package-release.sh (pnpm test:package-release, the CI package-release gate) builds the train twice, the second time from a separate worktree, requires byte-identical release directories, then runs the standalone consumer proof on the release: manifest checked, offline install from the tarballs only, lockfile integrity equal to the manifest's, frozen-lockfile reinstall, no Drizzle, one React, contracts only transitive, every @ekklesia package and module-pack at the release version, skipLibCheck: false, UI build, pack, and Core verification. - docs/development/releases.md documents the release order, the train, the artifacts, and the reproducibility findings; registry publication remains unimplemented. - ADR 0053 is Accepted: everything it decides that does not need a registry is now proven, and registry provisioning is on its own Deferred list. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1119 lines
77 KiB
Markdown
1119 lines
77 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.** Phase 6 completed through 6D, and **Phase 7A passes 1 (the minimal
|
||
operational module lifecycle), 2 (the
|
||
module configuration and secrets contract), 3 (host-provided module object storage), 4 (direct
|
||
browser object delivery), 5 (native module realtime transport), and 6 (the authenticated display/
|
||
kiosk host) have landed. Phase 7B pass 1 adds the Dashboard's host-owned browser sign-in, sign-out,
|
||
session-expiry handling, and authenticated-shell transition.** The operational
|
||
requirements that remain are prerequisites for two specific *future* activities — executing
|
||
untrusted CI contributions, and a public production launch — and neither gates ongoing product
|
||
development; see the
|
||
pass 5 closure below and
|
||
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes
|
||
public module contracts, independently versioned capabilities, static module manifests,
|
||
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.
|
||
|
||
**Ekklesia ships one generic image, and installed modules live outside it
|
||
([ADR 0048](docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).** The
|
||
production image built from [`deploy/Dockerfile`](deploy/Dockerfile) contains zero feature modules
|
||
and is the same for every installation. Installed modules live in installation-owned module storage,
|
||
selected by `EKKLESIA_MODULES_DIR` (`/var/lib/ekklesia/modules` in the image, the checkout's
|
||
`modules/` when unset) and mounted read-only by the reference deployment. Each installed module is a
|
||
self-contained directory carrying its own production dependencies. Installing, updating, or removing
|
||
a module needs only a controlled restart and, when it has migrations, an explicit plan and apply.
|
||
Nothing is hot loaded. Removing a module's code keeps its data and its stored state; its applied
|
||
migration history is retained and reported, and blocks no other migration. A module is distributed
|
||
as one **Ekklesia Module Package v1**
|
||
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
|
||
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
|
||
bundled, and checked by Core's verifier before it is installed. Two targets exist:
|
||
- the portable `any` target, for pure-JavaScript modules;
|
||
- `linux-x64-glibc`, for modules with a native dependency such as `sharp`. Its native runtime
|
||
packages are carried as real files under the package's `node_modules`, and the verifier checks the
|
||
host before anything is loaded.
|
||
|
||
Every package records the module's validated SPDX expression and carries its explicitly
|
||
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
|
||
packages may bundle a module-owned Drizzle version without coupling it to Core;
|
||
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
|
||
omits licence material, the packager may add reviewed licence supplements
|
||
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). Purge, other
|
||
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
|
||
|
||
A module can be built in its own repository from Ekklesia's public module-development packages
|
||
([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md)):
|
||
a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and
|
||
packaging tools. They are released as one coordinated train of tarballs with a release manifest,
|
||
and CI proves on every run that two builds of a commit are byte-identical and that a module outside
|
||
the repository installs, builds, packages, and verifies from those artifacts alone. They are not
|
||
published to any registry yet
|
||
([Releases and the public package train](docs/development/releases.md)).
|
||
|
||
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).
|
||
Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
|
||
enrollment still do not.
|
||
Public registration remains disabled by default and prohibited in production — and a fresh
|
||
production installation now has a supported way to get its first administrator anyway: Phase 7B
|
||
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
|
||
but not a finished product UI or a production asset-hosting deployment; jobs, production
|
||
approval-provider composition, feature modules, and the public Website remain later slices.
|
||
|
||
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
|
||
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 —
|
||
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
|
||
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
|
||
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
|
||
disposable runner isolation. Those are
|
||
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||
development proceeds under the current trusted-contributor, not-yet-deployed model. Runner topology
|
||
and job-token authority remain unverified and are not described as isolated. See
|
||
[Security model and deployment trust boundaries](docs/development/security.md).
|
||
|
||
Phase 6A established the **module product contract**. Ekklesia's modules are no
|
||
longer backend-only capability packages: a module is an independently versioned *product* that owns
|
||
its domain behavior, data, permissions, routes, migrations, and — new in this pass — **its own UI**.
|
||
Core keeps the containing application shell and every platform policy (authentication, authorization
|
||
enforcement, platform context, top-level routing, navigation, global theme, error boundaries,
|
||
trusted-origin/CORS). Modules stay **flat peers**: `video`, `livestream`, and `announcements` are
|
||
independent first-class modules with no parent/child relationship, and a Dashboard folder such as
|
||
`Media` is presentation configuration an administrator can reorganize, never module architecture and
|
||
never a dependency. The long-term goal is one module implementation hosted by Ekklesia *and*, later,
|
||
by a standalone application — never two codebases for one domain. See
|
||
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
|
||
|
||
**What that pass shipped at its checkpoint was the static contract only.** `manifestVersion: 4`
|
||
added one optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
|
||
during discovery exactly like `server` and `schema` — inside the package, real file, no source
|
||
TypeScript. Discovery stayed inert: **nothing imported, mounted, or rendered that entrypoint**,
|
||
exactly like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest was a
|
||
one-character edit; version 3 was rejected outright rather than reinterpreted, as version 2 was
|
||
before it. The Dashboard shell, runtime mounting, and navigation named as absent at that checkpoint
|
||
landed in subsequent Phase 6 passes. A standalone host, module installer or marketplace, hot-loading,
|
||
and every real feature module still do not exist. Restart-based discovery, explicit operator-invoked
|
||
migrations, and the trusted compiled-module model are unchanged.
|
||
|
||
Phase 6B pass 1 answers the question Phase 6A deliberately left open: **what a module UI actually is,
|
||
and what a host gives it.** Module UI is authored in **React and TypeScript**, and a module package
|
||
may now contain `.tsx` and depend on `react`/`react-dom` — every other UI framework stays rejected by
|
||
`pnpm boundaries`, and the layer rules are untouched, so module UI still cannot import Core, an app,
|
||
a surface, or another module. React is the supported *authoring* model but is deliberately **not** in
|
||
the contract: a module's compiled `entrypoints.ui` artifact default-exports
|
||
`defineModuleUi({ id, version, mount })`, and `mount(container, context)` receives a DOM element and
|
||
returns an unmount function. A host needs an element and a context, never a matching framework.
|
||
|
||
The new `@ekklesia/module-ui-sdk` package (no runtime dependencies; its one dependency on
|
||
`@ekklesia/contracts` is type-only) carries that contract for module authors and hosts alike. The
|
||
host context is deliberately five members — `moduleId`, `api`, `principal`, `permissions`, and
|
||
`realtime` — and nothing else. **A module UI asks its host for its own API by relative path**
|
||
(`api.request("posts")`)
|
||
and never constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private deployment detail; that
|
||
one rule is what keeps a future standalone host possible, and the test suite proves it by mounting
|
||
one fixture artifact under two hosts with different origins and URL shapes. **Client-side permission
|
||
hints are presentation only** — server-side `requirePermission` remains the sole authority — and
|
||
**module UI is trusted code in the host's own browser realm**: no iframe, no sandbox, matching the
|
||
backend trust model rather than pretending to improve on it. See
|
||
[ADR 0022](docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md).
|
||
|
||
Core still never loads browser code: discovery validates the `entrypoints.ui` path and never
|
||
evaluates it, and hosts do their own dynamic import.
|
||
|
||
Phase 6B pass 2 makes that contract **buildable end to end**. A module author writes React and
|
||
TypeScript at `src/ui/index.tsx` and runs its normal `pnpm build`; **esbuild** compiles it to the
|
||
browser ESM artifact the manifest declares. Authors write no bundler configuration — the new
|
||
`@ekklesia/module-ui-build` package owns all of it, and because it uses the existing `tooling` role
|
||
that modules could already depend on, **this pass required no boundary relaxation at all**. The
|
||
output path is read from the manifest rather than a build convention, so the declared and produced
|
||
artifacts cannot drift.
|
||
|
||
**React is bundled into each artifact rather than shared from the host.** That is the decision ADR
|
||
0022 implies: `mount()` is the ABI precisely so React is *not* part of the host contract, and
|
||
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
||
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
||
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
||
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
|
||
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
|
||
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||
and installing a module or starting the API never triggers one.
|
||
|
||
`templates/module` is the canonical example and now ships optional UI. Its tests build the artifact
|
||
from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered
|
||
output, and unmount it — so a UI that cannot be produced fails CI, and no generated file is ever
|
||
committed. See [ADR 0023](docs/architecture/decisions/0023-module-ui-build-pipeline.md).
|
||
|
||
Phase 6B pass 3 closes the last two foundational gaps. **The browser boundary now actually holds**:
|
||
`@ekklesia/module-ui-sdk` had one type-only import of `@ekklesia/contracts`, whose barrel pulled
|
||
Drizzle's types — and with them Node's globals — into every module UI program, so `process.env`
|
||
typechecked inside browser-only code. The SDK now has **no dependencies at all**, and owns a plain
|
||
browser `ModuleUiPrincipal` instead of Core's Zod-branded server type; a compile test proves that
|
||
supported UI source compiles and that `process` does not. **And there is now a generic host loader**:
|
||
`@ekklesia/module-ui-host` imports a published artifact from a browser URL, validates its id and
|
||
version against the manifest *before* mounting, mounts it into a host-owned container, and returns an
|
||
idempotent `unmount()`. A module may never depend on it — it is host infrastructure, and the boundary
|
||
checker enforces that. See
|
||
[ADR 0024](docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md).
|
||
|
||
Phase 6C pass 1 builds the publication layer those passes were missing. Core resolves
|
||
`entrypoints.ui` to a path on the *server's filesystem*, and a browser needs a **URL**; the platform
|
||
now serves one:
|
||
|
||
```text
|
||
GET /api/v1/module-ui/<module-id>/<version>
|
||
```
|
||
|
||
`:moduleId` and `:version` are **lookup keys, not path components**. They are matched against a
|
||
publication registry Core builds from the discovered, started module inventory, and only an exact
|
||
match causes the already-canonical artifact path discovery validated to be read. No request value is
|
||
ever joined into a filesystem path and the route declares no wildcard segment, so traversal, encoded
|
||
separators, sibling chunks, package-root files, and **source maps** are unreachable by construction —
|
||
**an installed module directory never becomes a static file root**, and only the single artifact its
|
||
manifest declares is served. A module that is not installed, one that declares no UI, and a wrong
|
||
version return one identical `404`; an artifact that vanished after discovery returns a sanitized
|
||
`503` carrying no path. The prefix is a *sibling* of `/api/v1/modules/`, not a child, because `ui` is
|
||
a legal module route segment and a module could otherwise collide with the platform.
|
||
|
||
**The route requires no authentication, deliberately.** Fetching the bundle confers no authority —
|
||
every module API stays behind `requirePermission`, and the host, not the artifact, supplies the
|
||
principal — while requiring a cookie would break cross-origin `import()`, which sends none. The cost
|
||
is stated plainly: a caller who already knows a module id *and* its exact installed version can
|
||
download that bundle. Caching is `no-cache` with a strong `ETag` rather than `immutable`, because
|
||
without an installer nothing stops a version being rebuilt with different bytes. See
|
||
[ADR 0025](docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md).
|
||
|
||
Phase 6C pass 2 connects the chain: **Ekklesia now has a browser host that mounts module-owned UI.**
|
||
`surfaces/shell` (`@ekklesia/shell`) is the repository's first surface — a small React application,
|
||
built to static files with esbuild — and it does the whole loop at run time:
|
||
|
||
```text
|
||
GET /api/v1/principal → who is viewing
|
||
GET /api/v1/module-ui → which module UIs may be mounted, and where
|
||
↓ server-produced artifact URL
|
||
@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container
|
||
↓
|
||
context.api.request("records") → /api/v1/modules/example/records
|
||
context.realtime.subscribe("state") → host-owned live state transport
|
||
```
|
||
|
||
The inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
|
||
artifact URL, module API root, module realtime root, and the viewer's granted permissions **for that
|
||
module** — and nothing else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because
|
||
navigation metadata is a separate design. It is authenticated (`core.modules.ui.read`, baseline
|
||
`authenticated`) precisely because it is the module enumeration the anonymous artifact route
|
||
deliberately withholds. All three URLs are produced by the server's own canonical helpers, so the
|
||
browser never rebuilds Ekklesia's module prefixes and any can change without touching host or module
|
||
code.
|
||
|
||
**The shell statically imports no module.** Every module it can mount arrives at run time from the
|
||
inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no
|
||
module id, no `/api/v1/modules`, no `eval`, and no blob URLs. Permission hints come from the server's
|
||
own evaluator and remain presentation only: the end-to-end test proves a viewer holding no grant is
|
||
still refused `403` when the UI calls the API anyway. The host owns the container, the loading state,
|
||
and every failure; a broken module costs its own panel, not the shell. See
|
||
[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md).
|
||
|
||
Phase 6D pass 1 gives that shell a real **navigation model** — and pins down the rule it could
|
||
easily have broken. A church sees `Media → Livestream, Video` and `Planning → Calendar`, while the
|
||
modules underneath stay exactly what they were: flat, independently versioned products with no
|
||
parent, no owner, and no folder-derived dependency.
|
||
|
||
**Grouping is presentation configuration, not module structure.** A group is a label on a rendering
|
||
bucket: no manifest, no version, no artifact, no API, no lifecycle, and nothing depends on it.
|
||
Placing Livestream and Video in one folder creates no relationship between them, and moving a module
|
||
changes one row. *Livestream depends on Video because it calls Video's functionality* is a real
|
||
dependency that orders startup; *Livestream is shown under Media* orders nothing. That distinction is
|
||
executable: a test discovers real fixtures with a genuine dependency, arranges them four ways
|
||
(including both in one folder, and the dependency inverted in presentation), and asserts discovery
|
||
order, dependency declarations, and the module set come out byte-identical every time.
|
||
|
||
**No manifest change, and no version 5.** `manifest.name` has been required since version 1 and is
|
||
already the module's product identity, so the shell simply renders "Livestream" instead of
|
||
`livestream`. `suggestedGroup` was deliberately deferred rather than shipped: it would force a hard
|
||
manifest-version cutover for a *hint*, and its only correct semantics needs initialization logic that
|
||
would risk a module's suggestion overwriting an administrator's decision.
|
||
|
||
Two Core tables (migration `0003`) hold groups and placements — server-side, because a church
|
||
configures its Dashboard, not one browser; platform-global, because Ekklesia has no organization
|
||
model yet. `GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the
|
||
host-neutral module UI inventory rather than merged into it, embedding each generic descriptor
|
||
untouched. Reading needs the same permission the inventory does; organizing needs a new platform
|
||
permission, `core.dashboard.navigation.manage`. A newly installed module appears immediately as
|
||
ungrouped with no initialization write; an uninstalled module's placement is retained so reinstalling
|
||
restores its position; deleting a group ungroups its modules through the database rather than
|
||
application cleanup. See
|
||
[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md).
|
||
|
||
Phase 7A pass 1 gives the platform an **operational module lifecycle** — the smallest one that is
|
||
actually useful, and nothing beyond it. Until now the accepted module set was exactly "whatever was
|
||
found on disk at startup", and `ModuleLifecycleStage` named nine states that nothing produced or
|
||
consumed. That vocabulary is replaced by two axes that real code produces:
|
||
|
||
```text
|
||
configuration enabled / disabled persistent, administrator-owned
|
||
runtime running / disabled / blocked observed per boot
|
||
```
|
||
|
||
**Enablement is host configuration, and a module can never see it.** One Core table (migration
|
||
`0004`) holds `module_id` and `enabled` — no install source, download URL, update channel, signature,
|
||
or JSON blob, because those are the columns an installer would need and their absence is what stops
|
||
one being built by accident. A module never declares or reads its own enabled state, and is never
|
||
told it is enabled: its host loads and starts it, or does not. That is what keeps the same module
|
||
implementation runnable under a future standalone host.
|
||
|
||
**A newly discovered module is enabled, and the default is the absence of a row.** Deploying a module
|
||
is still sufficient to run it — no initialization write, no database edit — mirroring the rule that a
|
||
module with no Dashboard placement is still visible. An explicit record always wins, in both
|
||
directions, and a record for a module that is no longer deployed is retained and ignored, so
|
||
redeploying restores the decision.
|
||
|
||
**Required dependencies block; optional ones never do.** Disabling `storage` does not silently
|
||
re-enable it because something needs it — it makes every module that *requires* it ineligible,
|
||
transitively, with a structured reason (`required_dependency_disabled` →
|
||
`required_dependency_blocked`) naming the module an administrator must act on. A required *capability*
|
||
dependency blocks identically, because its only provider is a module. An optional dependency being
|
||
disabled is the same situation as it never being installed: the dependent starts, and
|
||
`capabilities.resolve(...)` returns `undefined`.
|
||
|
||
**Exposure follows eligibility, structurally.** `startApi` hands the same eligible list to the module
|
||
runtime and to the UI publication registry, so a disabled module is never imported at all: no hooks,
|
||
no routes (an unmounted path gets the ordinary `404` — there is no per-request enablement check
|
||
anywhere), no capabilities, no published artifact, and no entry in the browser inventory or Dashboard
|
||
navigation. Its **migrations are still discovered and applied**, because deployed schema ownership is
|
||
not runtime state, and its **permissions stay registered**, so grants survive an enable/disable cycle.
|
||
|
||
**Startup stays all-or-nothing, deliberately, and changes need a restart.** One module that fails to
|
||
start still takes API startup down; that guarantee is what ADR 0025/0026/0027 rest on, and inverting
|
||
it is a separate design. There is consequently no `failed` runtime state — no live process could
|
||
report one. Enablement is what makes a broken module recoverable, with `pnpm modules:disable --
|
||
--module <id>` as the out-of-band path, since the HTTP API is served by the process a bad module
|
||
takes down. `PUT /api/v1/module-lifecycle/<id>/enabled` writes a decision and reports honestly that
|
||
the process has not changed:
|
||
|
||
```json
|
||
{ "configured": { "enabled": false, "explicit": true },
|
||
"runtime": { "state": "running" },
|
||
"restartRequired": true }
|
||
```
|
||
|
||
Nothing is hot-unloaded to make a button feel immediate. `running` means a module's `start` hook
|
||
completed and **nothing more** — a module that starts and then cannot reach its object storage has a
|
||
health problem, not a lifecycle problem, and this pass adds no health-probe contract. `pnpm doctor`
|
||
gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading
|
||
logs. Reading and changing both require the new restricted platform permission
|
||
`core.modules.lifecycle.manage`. Disabling a module is an **operational** control, not a sandbox: its
|
||
code is still on disk and still trusted. There is still **no installer**. See
|
||
[ADR 0028](docs/architecture/decisions/0028-module-operational-lifecycle.md).
|
||
|
||
Phase 7A pass 2 adds the **module configuration and secrets contract** — the thing standing between
|
||
Announcements and the module system, since that product currently has zero environment variables and
|
||
compiles a LAN IP address into its source.
|
||
|
||
A module declares what it needs configured in its manifest, and receives it as a frozen, validated
|
||
snapshot:
|
||
|
||
```json
|
||
"configuration": [
|
||
{ "key": "slideSeconds", "type": "integer", "default": 7, "minimum": 1, "maximum": 600 },
|
||
{ "key": "bucket", "type": "string", "required": true },
|
||
{ "key": "storageCredential", "type": "secret", "required": true }
|
||
]
|
||
```
|
||
|
||
```ts
|
||
start({ config }) {
|
||
const slideSeconds = config.getInteger("slideSeconds");
|
||
const credential = config.getSecret("storageCredential");
|
||
}
|
||
```
|
||
|
||
**Declaration is static, and that is the whole point.** The platform validates configuration
|
||
*before* deciding whether a module may start, so an enabled module missing a required value is
|
||
reported `blocked · configuration_missing · bucket` — never imported, never able to take startup down
|
||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
|
||
hard cutover; a version 7 source module or package must be updated and rebuilt.
|
||
|
||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||
no key management, so a `secret` key's value is supplied by the deployment
|
||
(`EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>`) and never touches PostgreSQL. The module never sees
|
||
that variable name — it calls `config.getSecret("storageCredential")` — which is exactly what lets a
|
||
future standalone host supply the same declared secret from somewhere else. A `secret` declaration has
|
||
no `default` field, the write API refuses secret material outright, and the HTTP view has no `value`
|
||
field for one, so a secret is absent from responses *by construction* rather than filtered by a branch
|
||
somebody could delete.
|
||
|
||
Six types (`string`, `integer`, `boolean`, `enum`, `url`, `secret`), bounded constraints, no nesting,
|
||
no regular expressions — a validation vocabulary, not a form-builder DSL. Nothing is coerced:
|
||
`{"slideSeconds": "7"}` is a `400`, and a stored value that fails its own declared constraints blocks
|
||
the module rather than being quietly replaced by the default. Ordinary values live in one Core table
|
||
(migration `0005`), one row per `(module_id, key)`; a key the current manifest no longer declares is
|
||
retained and ignored so a downgrade never destroys an operator's value.
|
||
|
||
Changes take effect at the **next restart**, and `GET`/`PUT /api/v1/module-configuration/<id>` report
|
||
`restartRequired` rather than pretending otherwise — a sibling prefix, because `config` is a legal
|
||
module route segment. Both verbs need the new restricted `core.modules.configuration.manage`,
|
||
deliberately separate from the lifecycle permission: running a module and configuring it are different
|
||
authorities. A setting that must change *without* a restart is the module's own domain state — like
|
||
Announcements' `mode` toggle — not platform configuration.
|
||
|
||
**There is no settings UI**, and module UI receives no configuration at all in this phase. Endpoints
|
||
and credentials for shared infrastructure stay platform concerns: a module declares which *bucket* it
|
||
needs, not an S3 endpoint, leaving room for the object storage service Core still lacks. See
|
||
[ADR 0029](docs/architecture/decisions/0029-module-configuration-and-secrets.md).
|
||
|
||
Phase 7A pass 3 turns `manifest.storage.buckets` — declarable since manifest version 1 and inert ever
|
||
since — into a real **host-provided object-storage capability**. This is what stands between
|
||
Announcements and the module system: it currently fetches `http://10.40.0.2:9000/announcements`, parses
|
||
S3 XML by hand, and hands the browser direct S3 URLs.
|
||
|
||
A module declares a **logical** bucket and gets a scoped facade:
|
||
|
||
```json
|
||
"storage": { "buckets": [{ "id": "announcement-assets" }] }
|
||
```
|
||
|
||
```ts
|
||
const assets = context.storage.bucket("announcement-assets");
|
||
const object = await assets.get("slides/welcome.png");
|
||
return new Response(object.body, { headers: { "content-type": object.contentType } });
|
||
```
|
||
|
||
**The contract names no provider.** No endpoint, region, credential, or physical bucket is reachable
|
||
from it — not as a field, not as a type, not as a string. That is what lets the same module run under
|
||
Ekklesia and under a future standalone host backed by its own SeaweedFS, a shared one, MinIO, or AWS
|
||
S3. A module that configures its own S3 endpoint is a module welded to one deployment, which is the
|
||
mistake this pass exists to prevent.
|
||
|
||
Core's first provider speaks the S3-compatible protocol through the AWS SDK. SeaweedFS is the intended
|
||
target ([ADR 0004](docs/architecture/decisions/0004-replace-unmaintained-minio.md)) and already runs in
|
||
the development Compose stack — but it is a deployment choice, never an API name. Probing it directly
|
||
found a real divergence worth having caught: `CreateBucket` on an existing bucket reports
|
||
`BucketAlreadyExists`, **not** AWS's `BucketAlreadyOwnedByYou`, so treating only the AWS spelling as
|
||
success would have failed on every second boot. A live test against the real server pins it.
|
||
|
||
**Logical → physical mapping is deterministic and collision-safe.**
|
||
`ekklesia-announcements--announcement-assets-3f0c1d9a6b21`: an installation prefix (so several
|
||
deployments can share one storage service), the module and bucket ids joined by `--` — which cannot
|
||
occur inside either identifier, so `a-b`+`c` and `a`+`b-c` can never collide — and a digest over the
|
||
full untruncated identity. Two modules both declaring `assets` always get separate namespaces, and a
|
||
module can never see or choose the physical name.
|
||
|
||
**Declaring a bucket is now a real requirement.** With no provider configured, such a module is
|
||
reported `blocked · storage_unavailable` naming the logical bucket, and its required dependents block
|
||
behind it through the existing resolver — no second eligibility system. An installation with *no*
|
||
object storage remains a complete deployment; modules declaring no bucket are unaffected, which is why
|
||
the template now declares none.
|
||
|
||
**A storage outage is not a lifecycle event.** Eligibility asks whether storage is *configured*, never
|
||
whether it answered. Bucket creation is lazy on first use rather than at startup, so a storage service
|
||
being down can never stop the platform from booting — and a running module whose object store blinks
|
||
stays `running`, catches a structured error, and retries, exactly as Announcements does today. Nothing
|
||
ever deletes a bucket, on disable or otherwise.
|
||
|
||
Credentials are deployment-supplied (`S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
|
||
`S3_BUCKET_PREFIX`), never persisted, never routed through module configuration, and never visible to
|
||
modules, module UI, diagnostics, or any API response — errors are translated to a closed code set that
|
||
carries no endpoint, bucket, request id, or XML. This also retires the transitional `bucket`
|
||
*configuration* key ADR 0029 flagged: the manifest declaration is the requirement.
|
||
|
||
Multipart upload is deliberately deferred, and the provider is not yet "production-ready" by
|
||
ADR 0004's own bar. See
|
||
[ADR 0030](docs/architecture/decisions/0030-host-provided-module-object-storage.md).
|
||
|
||
Phase 7A pass 4 takes Ekklesia **out of the data path** for assets a browser is going to fetch
|
||
anyway. A module asks its logical bucket for a temporary browser URL and returns it; the browser
|
||
fetches the object from the storage service directly:
|
||
|
||
```ts
|
||
const { url, expiresAt } = await context.storage
|
||
.bucket("announcement-assets")
|
||
.createReadUrl("slides/welcome.png");
|
||
```
|
||
|
||
The contract still names no mechanism — no signing, no endpoint, no expiry format — so a host with no
|
||
S3 in it could satisfy the same method. The URL is **opaque** to module code and a **bearer
|
||
capability** to whoever holds it, so a module authorizes the request in its own route before asking
|
||
for one: storage bounds *reach*, never *identity*. Lifetimes run from one minute to six hours,
|
||
fifteen minutes by default, and out-of-range requests are refused rather than clamped. Modules
|
||
persist logical keys and generate URLs on demand; a stored URL would be a stored credential.
|
||
|
||
This is what makes media practical. A `Range` request against a delivery URL is answered by the
|
||
storage service directly — verified `206` with a correct `Content-Range` — so byte-range seeking needs
|
||
no proxy in Core. Signing is local, so a slideshow builds one URL per slide with no round trips, and
|
||
repeated calls inside a window return a byte-identical URL so browser caches and URL-keyed DOM
|
||
elements hold.
|
||
|
||
Because an S3-compatible signature covers the request's `Host`, a URL cannot be signed for one origin
|
||
and rewritten to another. The origin a browser uses is therefore a signing input
|
||
(`S3_DELIVERY_ENDPOINT`), defaulting to `S3_ENDPOINT` — so the standard deployment configures nothing
|
||
and delivery simply works. A reverse proxy in front of storage must pass the original `Host` through
|
||
unchanged; it may terminate TLS, because the scheme is not signed. Buckets stay private: an unsigned
|
||
request for the same object is refused, and a test asserts it. **Signed write and delete URLs are
|
||
deliberately deferred.** See
|
||
[ADR 0031](docs/architecture/decisions/0031-direct-object-delivery.md).
|
||
|
||
Phase 7A pass 5 adds native, host-neutral module realtime
|
||
([ADR 0032](docs/architecture/decisions/0032-native-module-realtime-transport.md)). During
|
||
`register`, a module declares a small, module-local channel and the permission that protects it;
|
||
during `start`, running work, and HTTP handlers, it publishes bounded JSON state through its own
|
||
scoped publisher. The publisher is inert by the time that module's stop hook begins. Core keeps only
|
||
the latest state and one pending update per subscriber. A module
|
||
must publish an initial state before startup completes, so every accepted subscriber receives one
|
||
atomic authoritative snapshot and then monotonically sequenced updates without a GET-then-subscribe
|
||
race. Slow clients never block publishers and intermediate states may be coalesced.
|
||
|
||
Ekklesia carries that contract over permission-gated Server-Sent Events, with cookie authentication,
|
||
15-second transport-owned keepalives, a five-minute reauthorization cycle, browser-native reconnect,
|
||
and a fresh snapshot on every reconnection. The module UI API remains transport-neutral:
|
||
|
||
```ts
|
||
const subscription = context.realtime.subscribe<SlideshowState>("state", {
|
||
onSnapshot: (state) => render(state),
|
||
onUpdate: (state) => render(state),
|
||
});
|
||
|
||
// Return this from `mount`, or call it from your unmount cleanup.
|
||
subscription.close();
|
||
```
|
||
|
||
There is no durable replay, global topic namespace, cross-module subscription, query-string token,
|
||
Socket.IO contract, or MQTT integration. Direct object-delivery URLs are opaque JSON strings and
|
||
pass through realtime unchanged.
|
||
|
||
Phase 7A pass 6 adds the smallest complete **authenticated display/kiosk host**
|
||
([ADR 0033](docs/architecture/decisions/0033-display-kiosk-host-contract.md)). Manifest version 6
|
||
adds an optional, independently declared display role:
|
||
|
||
```json
|
||
{
|
||
"entrypoints": {
|
||
"display": {
|
||
"artifact": "./dist/display/index.js",
|
||
"permission": "announcements.display.view"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
The permission must be declared by that module and have an `authenticated` or `restricted` baseline. Primary UI and display UI are independently optional, must
|
||
use paths distinct from every other entrypoint, and use the same
|
||
`defineModuleUi({ id, version, mount })` ABI and five-member `ModuleUiHostContext` with **no mode
|
||
flag**. The canonical builder reads fixed sources (`src/ui/index.tsx` and
|
||
`src/display/index.tsx`) and atomically emits every declared role.
|
||
|
||
Phase 7B pass 2 gives those screens an identity of their own
|
||
([ADR 0036](docs/architecture/decisions/0036-managed-device-identity-and-pairing.md)), so a
|
||
television no longer needs a person signed into it. Ekklesia now recognizes **three** principal
|
||
classes — anonymous, authenticated human, and managed device:
|
||
|
||
```text
|
||
screen opens /display/announcements → no identity → shows a 6-digit code
|
||
administrator types the code in the Dashboard, names the screen, approves
|
||
screen collects an HttpOnly credential → display and realtime work
|
||
administrator revokes → the screen loses access and shows a code again
|
||
```
|
||
|
||
**A device is not a user.** It has no email, no password, and no row in the identity tables, and it
|
||
satisfies the `authenticated` baseline *never* — so every route that settles for "somebody is signed
|
||
in" stays closed to a screen on a wall. Device authority runs through the same authorization
|
||
evaluator as everyone else's, and approval grants exactly the one display permission the host derived
|
||
from the module's own declaration. `/api/v1/principal` reports a device as anonymous on purpose: a
|
||
device cookie cannot open the Dashboard.
|
||
|
||
**The code is not the credential.** Six digits are printed across a television in a public hallway,
|
||
so they are treated as public: they identify a request to an administrator and yield nothing on their
|
||
own. Collecting the credential also requires an HttpOnly claim cookie only the requesting browser
|
||
holds, and approving requires the restricted `core.devices.manage` permission. The credential itself
|
||
is stored only as a SHA-256 digest, never logged, never in a URL, and never readable by the module
|
||
display code running in that same page.
|
||
|
||
Revocation is a server-side fact checked on every request, not a plea that a remote browser forget
|
||
something; re-pairing creates a **new** identity so an old credential can never come back. A revoked
|
||
screen fails its next request immediately, though an already-open realtime stream is bounded by the
|
||
existing five-minute forced reconnect rather than closed instantly. **QR scanning, per-device content
|
||
targeting, device groups, and mobile dashboards are deliberately deferred** — a future QR simply
|
||
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
|
||
identity at all.
|
||
|
||
Phase 7C pass 1 adds the platform's first **outbound email transport**
|
||
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
|
||
authenticate people and pair devices but could not send a single message, which is what blocked
|
||
email verification, password recovery, and invitations alike. Email is now a host-provided
|
||
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
|
||
this transactional message* and the provider owns the host, port, TLS, credentials, and every
|
||
transport error. **No provider's hostname appears anywhere in Core**, and a test asserts it —
|
||
Google Workspace SMTP Relay is the expected first deployment, not a platform dependency.
|
||
|
||
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
|
||
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
|
||
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
|
||
mail appears to come from. Control characters in addresses, subjects, and display names are refused
|
||
rather than stripped, which is what closes header injection.
|
||
|
||
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
|
||
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
|
||
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
|
||
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
|
||
relay authorized by source IP has neither — and the password is a deployment secret that never
|
||
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
|
||
|
||
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
|
||
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
|
||
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
|
||
single bounded attempt and deliberately not an exactly-once promise. There is **no
|
||
`POST /api/v1/send-email`** and no route that touches the provider.
|
||
|
||
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
|
||
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
|
||
while stating plainly that a successful connection does not establish deliverability. See
|
||
[Outbound email](docs/development/email.md).
|
||
|
||
Phase 7C pass 2 completes the human identity lifecycle over that transport
|
||
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
|
||
verified email address is a precondition for holding a session**: the identity system refuses an
|
||
unverified sign-in after checking the password and before creating one, so a correct password on an
|
||
unverified address carries exactly the authority of no password at all — which is why no
|
||
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
|
||
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
|
||
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
|
||
can steer where a recipient lands, and following a verification link deliberately mints **no
|
||
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
|
||
leaves managed devices untouched. Password recovery answers identically for an address with an
|
||
account, without one, already verified, or undeliverable — including when SMTP is broken, which is
|
||
why the delivery adapter never throws.
|
||
|
||
**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates
|
||
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
|
||
can be signed in to before any relay exists. Normal production use of verification and recovery does
|
||
require configured outbound email. **Public signup remains disabled** — verification is not a licence
|
||
to self-register — and invitations and user administration are still not built. See
|
||
[Identity](docs/development/identity.md).
|
||
|
||
Ekklesia publishes the display bundle at
|
||
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
||
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
|
||
`GET /api/v1/module-displays/<module-id>`. The display surface is a separate full-viewport browser
|
||
application with no Dashboard chrome, no static module imports, and manual retry on failure. Only
|
||
eligible, running modules receive routes; manifest changes take effect on restart. Artifact bytes
|
||
remain anonymous trusted code, while the page and descriptor use normal cookie authentication and
|
||
the module-owned display permission. Realtime subscriptions and opaque signed object-delivery URLs
|
||
flow through the unchanged host context. Display inventory/management, variants and targeting,
|
||
public/device identity, browser Fullscreen API behavior, a standalone host, a shared CSS system, the
|
||
Announcements migration, and Companion integration remain deferred.
|
||
|
||
Phase 7B pass 1 adds **host-owned browser authentication to the Dashboard shell**
|
||
([ADR 0034](docs/architecture/decisions/0034-host-owned-browser-authentication.md)). Anonymous
|
||
bootstrap renders an accessible email/password form; successful submission uses the existing
|
||
`POST /api/auth/sign-in/email` endpoint, whose successful browser-visible body is reduced to fixed
|
||
`{"ok":true}` while the HttpOnly cookie remains authoritative. The shell resolves
|
||
`GET /api/v1/principal` again before protected navigation and module UI, and sign-out uses the
|
||
existing `POST /api/auth/sign-out` before the same principal re-resolution. No token is stored or
|
||
passed to a module, no arbitrary callback/return URL exists, and leaving authenticated state
|
||
unmounts the active module through the generic loader's normal cleanup.
|
||
|
||
The flow intentionally offers neither signup nor password recovery: production signup remains
|
||
prohibited, no email sender exists, and the repository still lacks a supported way to create the
|
||
initial production identity required by platform-owner bootstrap. Authentication throttling is
|
||
enabled but in-memory and per API process. Host-only cookies, exact trusted origins, and Better
|
||
Auth's origin/CSRF checks remain unchanged; production Dashboard DNS/TLS/proxy/CSP and built-asset
|
||
hosting are deployment work, not guarantees added by this pass. Display device identity and the
|
||
public Website remain separate future designs.
|
||
|
||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
||
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||
standalone host, and every real feature module also do not exist.
|
||
|
||
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
||
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||
declarations, typed facades, and frozen contexts are correctness boundaries, not a sandbox. Ekklesia
|
||
is also **not currently deployed**: there is no production instance, reverse proxy, public listener,
|
||
or deployment automation, so the origin/proxy/throttling properties a public launch requires are
|
||
unproven launch-blockers rather than existing guarantees. Read
|
||
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
||
module or exposing the API.
|
||
|
||
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
|
||
None of them is a feature module, and none of them names one.
|
||
|
||
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
|
||
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
|
||
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
|
||
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
|
||
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
|
||
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
|
||
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
|
||
badge, toast, and empty/error/loading states), every value a token reference, with real
|
||
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
|
||
exactly two shared roles to modules by name and pins both design packages beneath every other role.
|
||
The Dashboard and display install the defaults at their document root; custom properties inherit into
|
||
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
|
||
theme editor, brand builder, second theme, or component catalogue exists.**
|
||
|
||
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
|
||
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
|
||
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
|
||
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
|
||
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
|
||
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
|
||
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
|
||
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
|
||
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
|
||
is versioned with no dual acceptance; migrating is one `pnpm build`.
|
||
|
||
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||
the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its
|
||
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||
means. Publication requires that another eligible module provides the capability inside the declared
|
||
range; a contribution that fails is absent rather than broken. A consumer discovers through
|
||
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
|
||
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
|
||
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
|
||
database, storage, or credential — and loading one grants its module nothing.
|
||
|
||
Phase 8 pass 4 closes the object-storage write path
|
||
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
|
||
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
|
||
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
|
||
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
|
||
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
|
||
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
|
||
oversize is refused before authorization, the buffered path now counts what arrived rather than
|
||
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
|
||
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
|
||
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
|
||
|
||
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
|
||
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
|
||
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
|
||
every contributed renderer that display mounted, network-first so a screen can never be trapped on
|
||
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
|
||
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
|
||
stated rather than papered over — a display cannot render module content while offline, because
|
||
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
|
||
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
|
||
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
|
||
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
|
||
the module's responsibility and do not exist yet.**
|
||
|
||
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||
|
||
## Quick start
|
||
|
||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||
|
||
```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 and
|
||
outbound-email integration state.
|
||
|
||
Doctor reads its host infrastructure configuration through the same
|
||
`loadHostConfiguration(process.env)` the API executable uses, so both derive identical settings and
|
||
identical defaults from one deployment environment: configured object storage is reported as
|
||
configured, a module secret the deployment has set is reported as satisfied, and a value neither
|
||
process could accept is reported as a configuration failure rather than silently interpreted one way
|
||
by the diagnostic and another way at startup. An optional service that is genuinely absent — no
|
||
`S3_ENDPOINT`, no `EKKLESIA_SMTP_HOST` — is still a complete deployment and reported as skipped.
|
||
|
||
`.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
||
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
||
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
|
||
6 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
|
||
7 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
|
||
core:0006_device_identity · 1db97b7e3ac5
|
||
|
||
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.
|
||
|
||
Outbound email
|
||
○ Outbound email not configured
|
||
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
|
||
|
||
Result: READY WITH WARNINGS
|
||
```
|
||
|
||
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.
|
||
|
||
### Getting the first administrator into a fresh installation
|
||
|
||
A new installation has a login page and nothing to log in to: public registration is disabled by
|
||
default and cannot be enabled in production at all. One explicit, operator-invoked command creates
|
||
the initial identity and grants it platform ownership
|
||
([ADR 0035](docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
|
||
|
||
```bash
|
||
docker compose ... up -d # PostgreSQL and the rest of the stack
|
||
pnpm migrations:apply # the schema must already exist
|
||
pnpm identity:create-initial-owner -- --email [email protected]
|
||
pnpm api:start # then sign in at the Dashboard login page
|
||
```
|
||
|
||
It prompts for the password twice, invisibly, and never accepts one as a command-line argument —
|
||
`--password` is refused, because an argument is visible in shell history and in the process list.
|
||
For automated deployment it reads the password from standard input instead:
|
||
|
||
```bash
|
||
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
|
||
```
|
||
|
||
**No environment variable is read for the password and none belongs in `.env`**: it is used once to
|
||
create an account, not kept as deployment configuration. The command needs `DATABASE_URL` and
|
||
nothing else — no identity secret, no trusted origins, and no public base URL — so the first owner
|
||
can be created on a freshly provisioned server before TLS or DNS exist.
|
||
|
||
It runs **once**. Ownership bootstrap is single-use, so a second run is refused: it creates no
|
||
second owner, changes no existing password, and leaves the first owner untouched. It also never
|
||
applies migrations — an unmigrated database is reported, not silently fixed. If it created the
|
||
identity but failed before granting ownership, running the identical command again adopts that
|
||
identity and finishes, leaving its password alone. Creating *additional* users and owners belongs to
|
||
user administration, which does not exist yet.
|
||
|
||
This is not public signup and not a browser setup wizard: there is no `/signup`, `/register`, or
|
||
`/setup` route, no unauthenticated bootstrap endpoint, and no setup token. The login page stays
|
||
login-only.
|
||
|
||
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
|
||
(`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)
|
||
|
||
## License
|
||
|
||
Ekklesia is licensed under the **GNU Affero General Public License, version 3 or any later version**
|
||
(SPDX `AGPL-3.0-or-later`). The full text of version 3 is in [LICENSE](LICENSE). Its source is
|
||
available to you under the terms of that license.
|
||
|
||
> Ekklesia is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||
> Affero General Public License as published by the Free Software Foundation, either version 3 of
|
||
> the License, or (at your option) any later version.
|
||
>
|
||
> Ekklesia is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||
> the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
||
> General Public License for more details.
|
||
>
|
||
> You should have received a copy of the GNU Affero General Public License along with Ekklesia. If
|
||
> not, see <https://www.gnu.org/licenses/>.
|
||
|
||
Section 13 of the license adds an obligation for network use: if you modify Ekklesia and run your
|
||
modified version for users who interact with it remotely over a network, your version must offer
|
||
those users the opportunity to receive its Corresponding Source.
|
||
|
||
Official Ekklesia modules, published by the Ekklesia project, are licensed under `AGPL-3.0-or-later`
|
||
as well. Each module's own repository declares its license. The module template declares it in its
|
||
`package.json`, so a module started from the template begins with the same license.
|