Files
Ekklesia/docs/development/security.md
T
ekklesiaandClaude Opus 5.5 e36b70d8d4
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 08:38:14 -05:00

826 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Security model and deployment trust boundaries
The one canonical statement of what Ekklesia's boundaries actually protect against today, and what
they do not. Read it before installing a module, exposing the API, or reasoning about any other
document's guarantees.
Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md)
(compiled module runtime), [ADR 0016](../architecture/decisions/0016-module-database-access.md)
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
(module routing/lifecycle and migration execution),
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md)
(native module realtime),
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)
(authenticated display hosting), and
[Module development](../module-development/README.md).
## Installed modules are trusted compiled backend code
**Installing a module is equivalent to deploying trusted server code.** It is not equivalent to
installing a browser extension, a plugin from a marketplace, or anything else with a security
boundary around it. There is no such boundary here.
A module's `dist/server` entrypoint is imported into, and runs arbitrary JavaScript inside, the
`apps/api` process. Within the operating-system account that process runs as, a module can:
- read and write any file the account can reach, including `.env`, TLS material, and the source tree;
- read `process.env`, `process.argv`, and any other process state;
- open outbound network connections and bind listeners;
- import any installed dependency, including ones Ekklesia never intended it to use;
- monkey-patch Core's own exports, Node built-ins, `globalThis`, or another module's objects;
- keep running work after its lifecycle hooks return.
It also **shares the API's single PostgreSQL role and connection pool**. `ModuleDatabase.execute`
accepts arbitrary SQL, so a module has exactly the database authority the API has — every table in
the database, including `ekklesia_core_user`, `ekklesia_core_grant`,
`ekklesia_core_platform_owner`, `ekklesia_core_platform_owner_bootstrap`,
`ekklesia_core_authorization_audit`, and every other module's tables.
## What the module-facing mechanisms actually are
These are **correctness and coordination boundaries**. Every one of them assumes the module is
cooperative. None of them constrains a module that is not:
| Mechanism | What it actually provides |
| --- | --- |
| `ekklesia_*` table prefixes and schema discovery | Ownership clarity and collision avoidance between cooperating modules |
| TypeScript types, branded ids, contracts | Compile-time correctness; erased at runtime |
| `ekklesia.module.json` manifest and `manifestVersion` | Declared compatibility and dependency ordering |
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
| Scoped realtime channels, publishers, and subscriptions | Correct module-local state wiring, permission enforcement, and finite fanout — not process isolation |
| Display declaration, permission, and exact publication record | Correct role selection and normal authorization for an eligible/running module — not browser isolation |
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
| Capability registry | Explicit inter-module wiring, not access control |
| Bounded stop/rollback deadlines (ADR 0020) | The platform stops *waiting*; module code is not cancelled or terminated |
**There is no process, container, VM, seccomp/syscall, filesystem, network, PostgreSQL role,
schema-permission, table-ACL, row-level-security, tenant, or resource sandbox between a module and
Core.** A module is not isolated from Core, from other modules, or from the host.
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) lets a module declare its
own UI entrypoint. **That changes nothing about the authority described above.** A module that ships
UI is exactly as trusted as one that does not: its server entrypoint still runs in the API process
with that authority, and declaring `entrypoints.ui` neither adds nor implies any isolation.
### Module UI is trusted browser code, in the host's own realm
[ADR 0022](../architecture/decisions/0022-module-ui-authoring-and-host-contract.md) decides the
browser-side model that ADR 0021 deliberately left open. Module UI runs **in the same browser
JavaScript realm as the host shell**: no iframe, no sandbox, no cross-origin isolation, no capability
confinement. A module's UI can reach the host page, the DOM, `window`, and any global the browser
exposes, exactly like any other script the application loads.
That is a deliberate choice for a first-party, trusted-module platform, and it is consistent rather
than lax: a module whose server code already runs in the API process with the API's full PostgreSQL
authority is not meaningfully constrained by sandboxing its browser half. Do not describe module UI
as sandboxed, isolated, or confined — none of those are true, and no mechanism in this repository
implements them.
The contract does leave room for a future sandboxed tier without a redesign: `mount(container,
context)` passes an element and a plain-data context rather than shared object graphs, so a host
could implement it against an isolated realm. Nothing does today.
### Loading a module UI executes trusted code in the host page
`@ekklesia/module-ui-host` dynamically imports a module's compiled artifact from a URL and mounts it
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)). Once imported,
that artifact is ordinary same-realm JavaScript: it can reach the DOM, `window`, and anything else
the page can. **The loader is not a sandbox.** It validates that the artifact is the module the host
expected — the right id, the right version, a well-formed default export — and refuses to mount
anything else, but that is an integrity check against a *stale or mismatched* bundle. It is not a
judgement about whether a module is safe, and it must never be described as one.
The publication side carries the real obligation, and it now exists
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)). It honours the
rule this section demanded of it: **the existence of an artifact on disk is not what makes it
servable.** A module is publishable only if it is in the validated set the API accepted at startup
*and* its manifest declares `entrypoints.ui`.
### Publishing a module UI artifact exposes one file, not a directory
`GET /api/v1/module-ui/<module-id>/<version>` serves an installed module's compiled UI artifact. Both
segments are **lookup keys, not path components**: they are matched against a registry Core built
from discovered modules, and only an exact match causes the canonical path discovery already resolved
and containment-checked to be read. No request value is composed into a filesystem path and the route
declares no wildcard segment, so traversal, encoded separators, sibling bundle chunks, package-root
files (`package.json`, `.env`), and **source maps** are unreachable — not filtered out, but with no
code path that could reach them. `@ekklesia/module-ui-build` emits a real `.map` next to every
artifact, and it is deliberately not served: the manifest declares the entrypoint and nothing else,
and a source map embeds `sourcesContent`.
The server never executes what it publishes. The route reads bytes and writes bytes — no import, no
evaluation, no transformation — so a module UI whose top level throws is served like any other, and
ADR 0021's rule that discovery never runs `entrypoints.ui` is unchanged.
**The route requires no authentication, and that is a decision with a stated cost.** Retrieving the
artifact confers no authority: the bundle carries no credential, the host (not the artifact) supplies
the principal and permissions at mount time, and every module API remains behind `requirePermission`.
Requiring a session would also break cross-origin dynamic `import()`, which sends no cookie. The
cost is that **an anonymous caller who already knows a module id *and* its exact installed version can
download that module's UI bundle**, and thereby learn the module is installed. That is information
disclosure, not authority. Unknown module, no declared UI, and wrong version return one identical
`404`, so the route is not an enumeration oracle — but an operator who needs the bundle itself
confidential must keep the API off untrusted networks, exactly as the deployment assumptions below
already require.
Trust in a published artifact rests on discovery-time validation plus a request-time read of that
exact path. There is no signing, hashing, or provenance verification — Ekklesia has none to verify
against — so **publication is not evidence that a module is safe.** Only installed, reviewed modules
may be published or mounted. A module UI that mounts has gained no privilege it did not already have
— its server half already runs in the API process with the API's database authority — and server APIs
remain the sole authority for authorization regardless of what a browser loaded.
### The browser host mounts module UI; it grants nothing
Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through the resolved
`GET /api/v1/dashboard/navigation` view and mounts each embedded host-neutral descriptor with the generic loader
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). Three properties are
security-relevant.
**The inventory is authenticated, deliberately.** Module UI *bytes* are anonymous because fetching
compiled code confers no authority, and that route is carefully not an enumeration oracle. The
inventory is that enumeration — it lists which module products an installation runs — so it requires
`core.modules.ui.read` (baseline `authenticated`): any signed-in user, and nobody else. It carries no
filesystem path, package root, or entrypoint.
**Permission hints are computed by the server and remain hints.** For each listed module the API
evaluates that module's *own* declared permissions against the caller using Core's one authorization
evaluator, and returns only the granted ids — never a default of `true`, never inferred from a
client-side role, and never another module's or Core's authorization state. Every module route stays
enforced by `requirePermission` regardless; the test suite proves it by having a UI that was told
"denied" call its API anyway and still receive `403`.
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
performs a dynamic `import()` from the API's origin (the page's own in the same-origin topology, the
API hostname in the split one, ADR 0045) — no `eval`, no `new Function`, no `blob:` URL — and the
shell owns the container, the loading state, and every error, so a failing module costs its own panel
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above.
### Dashboard authentication stays outside module code
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md) makes the Dashboard
surface — not a module — the owner of login, logout, and session-transition UX. Principal resolution
still precedes every protected navigation request and module mount. The public module UI context
remains `moduleId`, scoped API, narrow principal, permission hints, and realtime; it receives no
credential, cookie, token, auth endpoint, or sign-out callback.
The browser posts email/password to Better Auth's existing `/api/auth/sign-in/email` endpoint with
credentials included. Better Auth establishes the HttpOnly cookie, but its native success JSON also
contains a redundant session token and user object. `apps/api` therefore normalizes any body-bearing
successful `2xx` on that exact route to fixed `{"ok":true}` while preserving the status and
`Set-Cookie`; browser application JavaScript never receives the token and must resolve
`/api/v1/principal` before authenticated rendering. The richer token-bearing native paths
`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
`/api/auth/change-password` are deliberately unavailable behind the same sanitized `404` as an
unsupported route. The last path is denied even though password/account management is out of scope:
the pinned endpoint may return a new raw session token when revoking other sessions. The form clears
the submitted password, suppresses duplicate attempts, uses fixed error messages, and does not log
credentials, raw responses, or caught exceptions.
Sign-out calls `/api/auth/sign-out` and then re-resolves the principal; hiding the shell alone is
never treated as invalidation. Leaving authenticated state removes the active module frame, so its
existing unmount closes API/realtime work without a module-specific auth event. A `401` from the
platform-owned bootstrap contract maps directly to unauthenticated; a platform navigation `403`
receives a principal recheck for the principal/navigation expiry race. Module responses are not host
session authority: either module `401` or `403` triggers one deduplicated principal revalidation,
while the module retains the original response. Only anonymous unmounts the module and shows login;
authenticated preserves the session and mounted module. A stale response from an already-unmounted
frame is ignored, and network/server failures do not fabricate logout.
The flow remains at the current Dashboard URL and accepts no callback/return target, so it adds no
open-redirect input. It exposes **no signup** link, because production signup is prohibited. It does
expose a recovery link, and the screens it leads to still accept no destination: the emailed link's
landing is parsed by a pure function that recognizes exactly two Ekklesia-owned values and names no
URL at all, and the link itself was built by the server from validated configuration rather than
from anything a request supplied (ADR 0038). The existing cookie stays host-only; exact trusted
origins, credentialed CORS, and explicitly enabled Better Auth origin/CSRF checks remain the
boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a
separate deployment and threat-model decision.
### A display page is authorized; its artifact bytes are not authority
The manifest binds a display artifact to one permission declared by the same module
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
and public/device identity is not approximated with an anonymous exception. Both the stable
`/display/<module-id>` page and `/api/v1/module-displays/<module-id>` descriptor evaluate that exact
permission through the normal Core evaluator. Client permission hints remain presentation only.
The versioned display artifact is anonymous code just like primary UI code. It is served only from
the frozen eligible/running publication registry, by exact module identity and version, and never
falls back to primary UI or opens the module directory. Fetching it grants no module HTTP, realtime,
storage, or data authority. Disabled and dependency-blocked modules receive no page, descriptor, or
artifact route; this is operational exposure control, not sandboxing.
The full-viewport host is a separate same-realm surface, not an iframe or containment boundary. It
uses normal cookie authorization, the same scoped API/realtime context, and opaque signed object URLs
without logging, proxying, extending, or re-signing them. It does not invoke the browser Fullscreen
API. Display inventory, device credentials, public principals, variants, and targeting are deferred
rather than silently granted.
### Dashboard arrangement is not an access control
Dashboard groups and placements are presentation configuration
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). Organizing the
Dashboard requires the platform permission `core.dashboard.navigation.manage` (baseline
`restricted`), and holding it **grants no module authority whatsoever**: moving a module into a
folder changes one presentation row, and every module route stays enforced against its own declared
permission exactly as before. Nothing in the navigation configuration is read by discovery,
dependency resolution, module startup, or the authorization evaluator.
Navigation is also not a way to hide anything. A module absent from a viewer's navigation is a
rendering decision, never a restriction — server authorization remains the only thing that decides
what a request may do. Reading navigation requires the same `core.modules.ui.read` the module UI
inventory requires, so it discloses the same installed-module enumeration and nothing more; it
carries no filesystem path, package root, or manifest internal.
### Disabling a module is an operational control, not a security boundary
A module can be turned off, and that decision survives restarts
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): a disabled module is
never imported, so its code never runs, it mounts no routes, registers no capabilities, and publishes
no UI. That is a real and useful operational property — it is how an operator recovers from a module
that misbehaves or breaks startup.
**It is not containment.** The module's compiled code is still on disk, still trusted, and still
carries every authority listed above the moment anything starts it. Enablement is a row in
`ekklesia_core_module_state`; it constrains the platform's own composition, not the module. Anyone
who can put a module package on this filesystem, or who holds the database or operating-system access
to change that row, decides what runs. Nothing about disabling a module sandboxes, isolates, or
reduces the privileges of a module that *is* enabled.
Changing enablement requires `core.modules.lifecycle.manage` (baseline `restricted`) and — for the
mutation — an exact trusted `Origin` and a bounded body. Reading is gated by the same permission
because the lifecycle view is the only place a disabled module, a blocked module, and the
required-dependency edges between them are visible; `core.modules.ui.read` deliberately exposes only
the mountable set. Neither response carries a filesystem path, package name, manifest path,
entrypoint, caught error, or stack — only module ids, capability ids, and fixed reason codes.
Holding the lifecycle permission grants no authority *inside* any module: every module route stays
enforced against that module's own declared permission, and a disabled module's permissions remain
registered and grantable precisely so that an enable/disable cycle changes nothing about who holds
what.
### Ekklesia does not store module secrets, and does not pretend to
A module declares a `secret` configuration key; the **deployment** supplies its value, and Ekklesia
never persists it ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
This is a deliberate limit, not an oversight. **There is no encryption at rest in this repository and
no key management** — no cipher, no KMS, no keyring. Storing credentials in a PostgreSQL column
because one is easy to add would be worse than not offering the feature, and calling such a column a
"secret store" would be worse still.
What that buys, structurally rather than by discipline:
- a `secret` declaration has no `default` field, so a checked-in default credential is
unrepresentable;
- the resolver never reads a secret from storage, so a row planted directly in the database by
someone with database access is ignored rather than used;
- the HTTP view has no `value` field for a secret, so a secret is **absent from responses by
construction**, not filtered by a branch someone could later delete;
- the write API refuses a `secret` key outright (`CONFIGURATION_SECRET_NOT_WRITABLE`);
- Doctor prints keys and reasons, never values.
A module reaches its own secrets through `config.getSecret("key")` and nothing else. There is no
`getEnv`, no arbitrary variable lookup, and no cross-module accessor: the secret source is asked for a
module id **and** a key that module's own manifest declares, so a module cannot read `DATABASE_URL`,
the identity signing secret, or another module's credential.
**What this does not claim.** A module is still trusted code in the API process (see above), so a
malicious module could read `process.env` directly and obtain anything the process can. Scoped secret
delivery is a correctness and portability boundary, exactly like every other module-facing mechanism
here — not a sandbox. What it does guarantee is that Ekklesia itself never writes a credential to
disk, never serves one over HTTP, and never prints one in diagnostics.
### Module configuration is operator input, and is treated as such
Ordinary configuration values *are* persisted, and are validated against the declaring module's own
manifest before anything reads them. A stored value that does not satisfy its declaration blocks the
module rather than being coerced, truncated, or silently replaced by a default.
Configuration keys are **data, never SQL identifiers** — there is no dynamic SQL in this domain — and
are rejected unless they match a closed lower-camelCase grammar that also excludes reserved object
property names (`__proto__`, `constructor`, `prototype`, ...). Core additionally never indexes a plain
object with a module-supplied key; every lookup goes through a `Map`. Store errors never carry the
offending value, because an operator-entered value may be a credential that ended up in the wrong
field.
Reading or changing configuration requires `core.modules.configuration.manage` (baseline
`restricted`), separately from `core.modules.lifecycle.manage`. Mutations require an exact trusted
`Origin` and a bounded body, like every other Core mutation. Module UI receives no configuration at
all in this phase.
### Object storage is scoped by binding, and modules never hold credentials
A module declares a logical bucket; the host resolves it to a physical bucket on an S3-compatible
service and hands the module a facade scoped to exactly that
([ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md)).
What that gives, structurally rather than by discipline:
- **No credential reaches a module.** The endpoint, region, access key, and secret key live in the
host's validated configuration and reach Core's provider only. `ModuleStorage` has no accessor that
could return any of them, and none appears in any error, diagnostic, or API response.
- **No module can open another module's bucket.** The facade closes over exactly the bindings that
module's manifest declared. Knowing another module's bucket name, module id, or physical bucket
name buys nothing, because none of those is an input to anything — an undeclared name is simply not
a key in the map.
- **No module chooses a physical bucket or an endpoint.** Both come from host configuration and the
deterministic binding, never from module input, so a module cannot aim the platform's S3 client at
a server of its choosing.
- **Two modules declaring the same logical name are isolated.** Physical names are derived from
module id *and* bucket id with a separator that cannot occur inside either, plus a digest over the
full identity — so `assets` in two modules is two namespaces, not one.
- **Object keys cannot escape a bucket**, because the bucket never comes from the key.
- **Nothing deletes a bucket.** There is no such operation to call, so disabling or removing a module
cannot take data with it.
- **Provider errors are translated, never forwarded.** No endpoint, request id, bucket name, or XML
body survives into a `ModuleStorageError` a module might log or return.
Endpoints must be absolute `http`/`https`, must not embed credentials, and must use `https` in
production — the same standard the database transport holds. Credentials are never persisted in
PostgreSQL and never routed through module configuration, which has no encryption at rest to offer
them (ADR 0029).
**What this does not claim.** A module is still trusted code in the API process, so a malicious module
could read `process.env` directly and obtain the provider credentials that way. Scoped storage is a
correctness and portability boundary like every other module-facing mechanism here — not a sandbox.
What it does guarantee is that Ekklesia never hands a module another module's data, never gives module
UI or a browser a credential, and never prints one in diagnostics.
### Delivery URLs are bearer capabilities, and are treated as secrets
A module can ask a bucket for a temporary URL that lets a browser fetch one object directly
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)). Whoever holds that URL can
read that object until it expires, with no further check of who they are.
- **Authorization happens before issuance, not inside the URL.** A module's route authorizes the
request and only then asks for a URL. The storage contract has no notion of a principal and offers
no hook that would let it make a permission decision; it bounds *reach*, not *who*.
- **Lifetimes are bounded and cannot be waived.** One minute at the shortest, six hours at the
longest, fifteen minutes by default. Out-of-range requests are refused rather than clamped, so
"expires forever" is unreachable rather than merely discouraged.
- **One object, one bucket.** The signature covers the URL's own path, so editing the key or the
bucket in a delivery URL yields a rejection rather than a different object — including another
module's object.
- **Never logged, never printed.** A complete delivery URL is treated exactly like a token: it is not
logged, and Doctor never prints one. Where a URL must be identified in a log, identify it by module
id, logical bucket id, key, and expiry — never by its query parameters.
- **Reads only.** There are no signed write or delete URLs. Uploads go through a module route where
the module can validate what it accepts.
- **Never canonical state.** Modules persist logical object keys and generate URLs on demand. A
stored URL is a stored credential with an expiry date in it.
**What a browser sees.** A delivery URL reveals the storage origin, the physical bucket name, the
object key, and signing parameters. Physical bucket names are implementation metadata, not secrets —
nothing is authorized by knowing one, since the bucket is not publicly readable and a tampered URL is
rejected. The guarantee is that *module code* cannot reach those details, not that a browser cannot
see the URL it is fetching.
**Buckets stay private.** Direct delivery deliberately does **not** work by making buckets anonymous;
an unsigned request for the same object is refused, and an integration test asserts it so that a
future change cannot "fix" delivery by opening a bucket up.
### Realtime is permission-gated state delivery, not a trust boundary
A module may declare a local realtime channel protected by one of its own manifest permissions. Core
closes both the server publisher and browser subscription over that module id: neither API accepts a
caller-supplied module namespace, global topic, wildcard, broker address, origin, URL, permission, or
credential. This prevents accidental cross-module wiring by a cooperating module. It does not
sandbox hostile installed code; trusted same-process backend code could bypass the facade, and
trusted same-realm UI could call browser globals directly.
Ekklesia's SSE endpoint resolves the ordinary session cookie and evaluates the channel's fixed
permission before allocating a subscriber. There is no query-string token and no second realtime
authentication path. A stream is closed after five minutes so native EventSource reconnects and
session, grant-expiry, revocation, and baseline policy are evaluated again; authorization is not
continuously queried between those boundaries. Disabled and dependency-blocked modules have no
stream route at all. Public or device/display identity remains deliberately unsolved, so no channel
is made anonymous merely to support a future kiosk.
Fanout is finite: 16 channels per module, 100 subscribers per channel, 64 KiB of validated serialized
JSON per publication, one latest snapshot per channel, and one coalescing pending slot per client.
A slow browser cannot block a publisher or create an unbounded queue. The endpoint rejects implicit
`HEAD` before allocation, disconnect/cancel closes its Core subscription, and server shutdown closes
streams before finite HTTP drain. Each scoped publisher becomes inert immediately before its own
module's stop hook.
Core never adds configuration, secret values, database values, storage credentials, or delivery URLs
to a realtime document. It transports only what trusted module code explicitly publishes. A module
must therefore treat realtime like any other authorized response: include only data every principal
holding the channel permission may receive. If it intentionally includes a signed delivery URL, that
URL remains a bearer capability and is forwarded byte-for-byte; fixed transport errors never reflect
payloads, URLs, permissions, runtime exceptions, paths, or stacks.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds
(`ModuleUiPermissions.has`), so the UI can hide an action or disable a control. **This is
presentation only.** Server-side authorization remains the sole authority: every module route is
enforced by `requirePermission` against the composed platform permission registry, and that
enforcement neither knows nor cares what a browser decided. Hiding a control is a usability
improvement, never an access control. Treat any claim that a UI check protects anything as a bug.
## What this platform must not claim
Ekklesia today has **none** of the following, and no document, README, or marketing surface may
imply otherwise:
- module signing, provenance attestation, or verified publishers;
- a marketplace, registry, or review process for third-party modules;
- sandboxed or hot-installed modules;
- per-module database roles, schemas, or credentials;
- tenant isolation;
- runtime resource limits (CPU, memory, file descriptors, connections) per module.
## Operator rule
**Only install modules whose source, provenance, and build output you have reviewed and whose
authors you trust with your production server and database.** Treat a module the way you would
treat a direct commit to `apps/api`: read the code, verify what artifact was built from it, pin it,
and re-review on upgrade. Do not install a module because a manifest looks well-formed — every
validation Ekklesia performs on a manifest is satisfied trivially by hostile code.
Installing, removing, or editing a module requires an API restart; there is no hot install path,
and none should be added while this model holds.
## What would have to change before accepting untrusted modules
Accepting third-party modules that have not been reviewed is an architectural change, not a
configuration change. At minimum it would require:
- process or container isolation per module, with the API as a supervisor;
- capability-based RPC across that boundary instead of in-process object passing;
- a separate PostgreSQL role and schema per module, with no shared superuser-equivalent pool;
- restricted filesystem and network access per module;
- CPU/memory/connection limits and enforced timeouts that can actually terminate work;
- signed, verifiable module provenance and a defined review/revocation process.
Until that exists, the trusted-code model above is the security model.
## Current deployment state
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no running
reverse proxy, no public listener, and no deployment automation in this repository — no deploy
script, no CI deployment step, no container publish. The repository provides a **reference deployment**
([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which
CI builds and smoke-tests end to end on every change against disposable services. Nothing pushes,
publishes, or runs it against a real installation. No `.env` has ever been committed; only
`.env.example`, which the image build context excludes.
The image contains no modules
([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)).
Installed modules come from installation-owned module storage, which the reference deployment mounts
**read-only** into `api` and `migrate`, so the Ekklesia process cannot modify installed code. That
is an integrity property, not a sandbox: an installed module is still trusted code with the API's
database role, exactly as above. The repository's boundary checks prove only source modules in a
workspace, and an installed package usually ships none, so installing one means trusting that
module's own CI and whoever placed it in module storage.
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
- `apps/api` defaults its listen address to loopback (`127.0.0.1`), so it is not reachable off-host
unless an operator explicitly changes it;
- production configuration refuses to enable public signup at all
(`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`).
A loopback default is not a firewall, and a config default is not a verified boundary.
### Launch checklist — each item must be proven against the real deployment before public exposure
Until every one of these is verified on the actual hosts, the deployment is not launch-ready. None
of them can be satisfied by reading this repository.
This checklist gates a **public launch**. It does not gate development: Surfaces, modules, and Core
work all proceed against the local development infrastructure while every item below is still
outstanding.
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
maps every item here to its configuration). A split installation (ADR 0045) puts its Dashboard
and API hostnames on one registrable domain and uses `Caddyfile.split.example`, which refuses
`/api/*` on the Dashboard's hostname so the session cookie has exactly one home.
2. The origin port is unreachable from the public Internet.
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
4. The proxy **overwrites** (never appends to, never passes through) the client-IP header Ekklesia
is configured to trust.
5. Rotating a caller-supplied `Forwarded`, `X-Forwarded-For`, `CF-Connecting-IP`, `X-Real-IP`, or
the platform's own internal sanitized header cannot rotate the authentication rate-limit bucket.
6. The actual immediate proxy peer is in Ekklesia's explicit trusted-proxy set, and an untrusted
direct peer's forwarded header is ignored.
7. Proxy logs do not record cookies, authorization values, request bodies, or raw forwarding chains.
8. PostgreSQL is reachable only from the API host, over TLS, with the certificate and hostname
verification Ekklesia already requires.
9. The deployed login/logout flow preserves the host-only HttpOnly cookie, returns fixed
`{"ok":true}` rather than a token on successful sign-in, keeps the four token-bearing native
session/password paths unavailable, and accepts no external/protocol-relative post-login
destination.
10. The installation has a supported, audited way to create its first production identity before
consuming the single-use platform-owner bootstrap. This repository does not provide that way
yet.
### The initial-owner command is trusted local tooling
A fresh installation needs a first administrator, and public registration is disabled everywhere and
prohibited in production. `pnpm identity:create-initial-owner` fills that gap
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)). What makes it safe
is **where it can be invoked**, not a weaker policy:
- **It is not reachable from a browser.** No `/signup`, `/register`, or `/setup` route, no
unauthenticated bootstrap endpoint, no temporary setup route, and no setup token. It serves no HTTP
route and nothing in `apps/api` imports it. Its authority is already having the database
credentials.
- **Public signup stays disabled.** The command constructs its own in-process identity instance
rather than relaxing the server's posture. A test asserts that a freshly bootstrapped deployment
still refuses `POST /api/auth/sign-up/email`.
- **The password never lands anywhere durable.** Prompted invisibly or read from standard input;
`--password` is refused rather than ignored, because an argument is visible in shell history and in
the process list. No environment variable is read for it and none is in `.env.example`, so it
cannot become permanent deployment configuration. It is never printed, logged, or placed in an
error.
- **It writes no password hash of its own.** Hashing, salting, normalization, and the 8–128 bound are
the identity system's, applied through the same sign-up path a browser uses — so a CLI-created
account can never diverge from a browser-created one.
- **It cannot become a standing superuser factory.** Ownership bootstrap is permanently single-use
and this command consumes the same sentinel; there is no `--force`, reset, or override. A second
run creates no owner, changes no password, and creates no stray identity.
- **It never takes over an account.** An existing usable identity is adopted — granted ownership with
its password untouched — and reported as an adoption. A password reset is never performed. An
identity that has no password at all is refused rather than made an owner nobody can sign in as,
which would consume the single use and leave the installation unusable.
- **Races cannot mint two owners.** Whole invocations are serialized by an advisory lock, on top of
the grant's own locked, single-use transaction.
- **Errors stay sanitized.** No SQL, connection string, driver detail, Better Auth response body, or
cause reaches the operator's terminal — only a stable code and a message describing what is still
true of the installation.
It deliberately does not solve creating *additional* users or owners. That is user administration,
which does not exist yet.
### A display device is a principal, not a person and not a shortcut
An unattended screen has its own identity, paired by an administrator and revocable by one
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). What makes that
safe is what it is *not* allowed to be:
- **Not a user.** No device row exists in the identity tables and no device has an email or a
password. A device satisfies the `authenticated` baseline **never**, so every route that settles
for "somebody is signed in" stays closed to a television.
- **Not a bypass.** Device authority is decided by the same `createAuthorizationEvaluator` as
everyone else's, with the same deny-by-default. There is no device branch that returns allowed
before consulting an explicit permission, and no device is ever a platform owner.
- **Not broadly granted.** Approval grants exactly the one display permission the host derived from
the module's own validated display declaration. A browser cannot ask for a permission of its
choosing, and a device paired for one display has no authority over another.
- **Not a Dashboard login.** `/api/v1/principal` reports a device as anonymous, and the shell tests
for `=== "authenticated"` rather than "not anonymous". A device cookie cannot open the Dashboard.
- **Not the pairing code.** The six-digit code is displayed on a wall and is treated as public. It
identifies a request to an administrator; it never yields a credential. Collecting the credential
additionally requires the HttpOnly claim cookie only the requesting browser holds, so reading a
code off a screen is worth nothing.
- **Not stored in a usable form.** The server keeps SHA-256 of the credential. The raw value exists
only in the `Set-Cookie` that issues it and in the device's own cookie jar; it is never logged, put
in a query string, returned in JSON, or written to `localStorage`. The cookie is `HttpOnly`, so the
module display code running in that very page cannot read it.
- **Not revocable only at the browser.** Revocation is a server-side column checked on every
principal resolution. A revoked screen keeps its cookie and stops working; re-pairing creates a
**new** identity, so an old credential can never be reactivated.
Approval, listing, and revocation require the restricted `core.devices.manage` permission and an
exact trusted `Origin`, exactly like every other Core mutation. A device cannot approve a pairing —
including its own.
**Bounded, not instantaneous.** A revoked device fails its next request and its next realtime
connection immediately, but an already-open SSE stream is not torn down at that moment: realtime's
five-minute forced reconnect is the upper bound on how long a just-revoked screen can keep receiving
a channel. That window is stated rather than described as instant.
### Outbound email is host-owned, and cannot be turned into a relay
Ekklesia sends transactional email through a host-configured SMTP transport
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). What keeps it from
becoming a liability:
- **No feature can choose a sender.** A message has no `from` field at all; the sender is host
configuration. Feature-chosen senders are an impersonation primitive and a deliverability
catastrophe at the same time.
- **No HTTP route can send.** There is deliberately no `POST /api/v1/send-email` and no route that
touches the mail provider — an authenticated relay endpoint is an open relay with extra steps. A
test asserts no route file even imports the mail module.
- **No module can send.** `ModuleRuntimeServices` carries no mail service, and a test asserts the
module SDKs have no way to. Browser code has none either: the SMTP dependency exists only in Core,
which a surface cannot import.
- **Header injection is refused, not repaired.** Control characters in addresses, display names,
subjects, and reply-to values are rejected. The first implementation trimmed before checking and
silently accepted a trailing carriage return; a test caught it and the check now runs on the raw
value.
- **Bounded everything.** Recipients (10), subject (200), text (64 KiB), HTML (256 KiB), and
connection, greeting, and socket timeouts are all explicit. A silent server returns a structured
error rather than hanging a request.
- **No downgrade.** Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than
inferred from a boolean and a port. `starttls` **requires** the upgrade, so a server that stops
offering it — or an attacker who strips it — fails the send instead of continuing in the clear.
Production refuses `plaintext`. Certificate verification is always on and there is no setting that
disables it.
- **The password is a deployment secret.** Never in PostgreSQL, never in module configuration, never
in an API response, never in Doctor output, never in an error, and no value in `.env.example`.
- **The SMTP client identity is not a secret.** `EKKLESIA_SMTP_HELO_NAME` is announced in the clear
in `EHLO` on every connection, so it is shown in Doctor output where an operator can compare it
with what the relay expects. It is validated as a fully qualified domain name — no scheme, port,
brackets, whitespace, or control characters — so nothing arbitrary reaches the wire.
- **Nothing is logged.** The transport logs nothing at all. Transactional mail carries verification
and reset links, which are exactly the credentials those flows depend on.
- **No blind retry.** One bounded attempt. A failure after the message data has been sent is
ambiguous, and retrying it would send someone several copies of the same reset email.
Deliverability — SPF, DKIM, DMARC, and the relay's own authorization policy — is a deployment
responsibility this repository cannot verify, and Doctor says so rather than implying a successful
connection means mail arrives.
### Email verification and password recovery
Both flows are Better Auth's, delivered over that transport
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
- **A verified address is a precondition for a session.** An unverified sign-in fails after the
password check and before any session exists, so a correct password on an unverified address is
worth exactly as much as no password. There is no "partly authenticated" principal to misread.
- **The initial platform owner is the one exception**, created already verified by an operator
command whose authority is possession of the database. It is exactly one address, exactly once per
installation, with no mail prerequisite — the alternative is an installation nobody can ever sign
in to.
- **Ekklesia stores no token.** No token table, no token hashing, no expiry clock, no consumption
logic. Verification tokens are signed JWTs valid for one hour and replayable but idempotent (mail
scanners dereference links); reset tokens are stored rows, valid for one hour, and consumed.
- **Following a link never creates a session.** `autoSignInAfterVerification` is off, so a URL
fetched by a link scanner, a prefetcher, or a mail gateway hands out nothing.
- **Links are built from configuration.** Only the token is taken from the identity system; the
destination comes from a validated Dashboard origin that must be canonical, https and non-loopback
in production, and a trusted origin — checked at startup. No request parameter can steer where a
recipient lands.
- **Tokens never reach a log, an error, or a diagnostic**, and the browser strips the reset token
from the address bar with `history.replaceState` before rendering the form. It is never written to
`localStorage`, `sessionStorage`, or a cookie.
- **Recovery is enumeration-safe, including when mail is broken.** Every user-facing answer is
identical for an address with an account, without one, already verified, or undeliverable. The
delivery adapter never throws, because the identity system rethrows what its callback throws and
that would turn a failed relay into an account oracle. Failures are reported out of band with a
sanitized code and no address, token, or link.
- **Password reset revokes that person's sessions**, including the one performing it. Managed device
sessions are untouched: a device holds a separate credential digest, no email, and no password.
- **Verification does not imply registration.** Public signup remains disabled and the browser offers
no way to ask for an account.
### Rate limiting is per-process today
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
only while **both** of the following hold, which is the current state:
- production public signup is disabled (and cannot be enabled — see above); and
- exactly one API instance serves traffic.
The same limiter covers the two paths that trigger an email — password-reset requests and
verification resends — at **3 requests per 60 seconds per IP**. That is a real control against
turning the platform into someone's mail cannon from one source, and it is deliberately **not**
claimed to be distributed: a multi-process deployment gets that budget per process.
If either changes — public signup is enabled, or a second instance is added — a durable shared or
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.
Ekklesia does not ship one. Do not describe the current limiter as distributed throttling.
Its production sign-in/sign-up-prefixed rule is three attempts per ten seconds, and the
server-owned client address prevents a spoofed forwarding header from selecting a fresh bucket.
Those facts still do not make counters durable across restart or sufficient across replicas; the
public Dashboard login must be assessed with the actual edge/proxy controls before launch.
## Continuous integration boundary
CI runs on self-hosted Gitea Actions runners. The server reports **Gitea 1.27.2** (`GET
/api/v1/version`, checked 2026-10-06; this document previously said 1.24.2, which was stale). The
server's actual version, not the documentation of an earlier one, determines which parts of the
workflow are real guarantees and which are not.
### The current operating rule: trusted code only
**This CI installation is a trusted-code-only boundary.** Only code controlled and reviewed by
trusted repository collaborators may execute on the self-hosted runner. An unknown or untrusted pull
request must **not** execute on it automatically — a maintainer reviews the diff, including any
change to the workflow itself, before it runs.
This is an operating rule, not an enforced technical control, and it is the reason the rest of this
section is safe today. Everything below describes what the repository can and cannot prove about
that runner; none of it is a substitute for the rule.
### What is true of the repository today
These are properties of files in this repository, enforced by the action or the package manager,
independent of server version:
- every action reference is pinned by a full immutable commit SHA;
- dependencies install with `pnpm install --frozen-lockfile`;
- there is no `pull_request_target` workflow;
- no repository, organization, or instance secret is referenced anywhere in the workflow;
- every checkout step sets `persist-credentials: false`;
- the workflow declares `permissions: contents: read`.
### What `persist-credentials: false` actually proves
It proves exactly one thing: `actions/checkout` does not write its credential into `.git/config`,
so a later step in the same job cannot read or reuse it from there.
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
reduce that token's server-side authority. A step that wants the token can still read it.
### What this server honours, and what is not yet verified
Each item separates what Gitea documents for 1.27
(<https://docs.gitea.com/1.27/usage/actions/comparison>) from what has been observed on this server:
- **`concurrency` is honoured.** Gitea supports it since 1.26 (go-gitea/gitea#32751), and on this
server a newer push to a pull request cancels the run it supersedes. Observed: Ekklesia run 133 was
cancelled when run 134 started for the same pull request, and run 135 when run 137 did. A
superseded run's own cleanup is therefore never guaranteed to run, which is one reason for the
stack sweep below.
- **`permissions` is supported by 1.27, with Gitea's own scopes, but is not verified here.** The
server-side token-permission settings are not visible through the API, and nothing has yet proven
what the job token can and cannot do on this server. Until that proof exists (requirement 3
below), this repository still does not claim the `GITEA_TOKEN` is read-only.
- **`timeout-minutes` is no longer listed as unsupported for 1.27, but is not verified here.**
Teardown does not rely on it: the stack sweep and a trusted reaper do.
- **Still ignored:** `jobs.<job_id>.environment`, and any `runs-on` other than a single label.
The declarations stay in the workflow either way: they state the intent, and are correct on GitHub
Actions as well.
### Disposable stacks never outlive their job
Every CI script removes its own Compose stack in an `EXIT` trap, but a trap never runs when the runner
kills the job container: a cancelled job, or the runner's own time limit. One such stack ran on the
shared runner for two days. So stack cleanup has three layers:
- **The script's own trap**, for every ordinary failure.
- **A final `if: always()` step** in every job that starts stacks: `scripts/ci-sweep.sh --job` removes
everything that job started, at any age, even after a failed step.
- **A sweep before every such job starts**: `scripts/ci-sweep.sh --stale` removes anything a CI job
left more than three hours ago, which catches whatever a killed job could not clean up itself.
A stack is found by name and label, never by guesswork (`scripts/lib/ci-project.sh`):
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job key>-…`, and Compose labels
its containers, networks, and volumes with that name;
- anything a script starts outside Compose carries `org.riverofglorymn.ekklesia.ci.project`;
- images also carry the time CI built them, because an image's own creation time can be its base's.
The job key is per matrix leg, so one leg's cleanup cannot match another's running stack. Nothing
outside the `ekklesia-ci-` prefix is ever removed. A job container attached to a stack's network is
disconnected, never removed.
**This is not a substitute for a runner-side reaper.** A stack killed with its job is removed only
when another job that sweeps runs on the same runner. Bounded execution and teardown are still the
runner host's to enforce, as above.
### Runner topology is not verified
**The runner topology is not verified in this repository and is not documented here as though it
were.** A pull request can edit `runs-on` and select any runner label its repository can reach, so a
workflow file cannot be the boundary. Whether untrusted pull-request jobs execute on a disposable
guest, and whether any runner reachable from this repository holds deployment authority or a host
Docker socket, must be established against the actual Gitea instance and runner hosts. Until that
evidence exists, treat pull-request CI as executing with whatever authority the runner host actually
grants.
### Requirements before executing untrusted contributions
These five items are the prerequisites for **relaxing the trusted-code-only rule above** — that is,
for letting code this project has not reviewed execute on this Gitea installation. They are backlog
requirements for that specific future capability. **They do not block Phase 6 Surface work**, and
they are not conditions on merging trusted, reviewed work.
None of them can be satisfied by editing this repository; each requires access to the Gitea server
and runner hosts, and each must be proven there.
1. ~~Upgrade Gitea to a reviewed supported release with job-token permission controls — at least
the 1.26 feature line.~~ **Done:** the server runs 1.27.2. Items 2 and 3 now configure and prove
what it makes possible.
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
code cannot request more than read-only repository access.
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
releases, packages, issues, or pull requests, change Actions configuration, or reach another
private repository.
4. Enforce job timeout and teardown in the trusted provisioner/reaper. `concurrency` is honoured
by the server; `timeout-minutes` is not yet verified, and the in-workflow stack sweep only
complements a reaper.
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
reusable key.
Moving future public contribution workflows to a service that provides isolated hosted runners — for
example GitHub — is an accepted alternative to satisfying items 1–5 on this installation.