617 lines
41 KiB
Markdown
617 lines
41 KiB
Markdown
# 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 same-origin dynamic `import()` — 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 this pass
|
||
adds no open-redirect input. It exposes no signup or recovery link: production signup is prohibited
|
||
and no email delivery/recovery system exists. 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
|
||
|
||
Manifest version 6 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 reverse
|
||
proxy, no public listener, and no deployment automation of any kind in this repository — no deploy
|
||
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever
|
||
been committed; only `.env.example`.
|
||
|
||
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 serves the built Dashboard assets and `/api` on the intended origin, or an
|
||
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
|
||
repository currently supplies neither deployment.
|
||
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.
|
||
|
||
### 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.
|
||
|
||
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 inventoried server is **Gitea 1.24.2**, and that
|
||
version 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 Gitea 1.24 ignores — so the branch cannot claim it
|
||
|
||
Per the official comparison for this version
|
||
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
|
||
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
|
||
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
|
||
|
||
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
|
||
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
|
||
1.24. Job-token permission controls, repository/owner defaults, and server-side
|
||
maximum-permission clamping arrive in the 1.26 feature line
|
||
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
|
||
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
|
||
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
|
||
|
||
The declarations are kept deliberately: they are already correct for a supported Gitea release and
|
||
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
|
||
are documented as ignored, not removed.
|
||
|
||
### 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. Select the actual release during infrastructure work, after backup and
|
||
compatibility review; do not pin a version from this document.
|
||
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, cancellation, concurrency, and teardown in the trusted
|
||
provisioner/reaper rather than relying on the ignored workflow keys above.
|
||
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.
|