ea28f8aa2e77647e34da83da85f6c5a55003dd53
31
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2b4f0afb72 |
fix: give Doctor the host configuration the runtime receives
`pnpm doctor` and `startApi` each read their own subset of the same environment variables, and the subsets had drifted. Doctor never read `S3_*` or module secrets at all, so a correctly configured installation was told its object storage was "not configured" and every module requiring a bucket or a secret was reported blocked -- while the runtime, from the identical environment, bound those buckets and ran the modules. Both executables now map the environment through one `loadHostConfiguration(env)` in Core, which takes the environment as an argument and never reads `process.env` itself. Validation stays with the existing per-domain validators, so each caller keeps its own failure behaviour: startup refuses a rejected value, Doctor reports it as a sanitized code. Doctor's storage option becomes raw input like its mail option, so a half-configured provider is reported as a blocking configuration failure instead of throwing before a report exists. The outbound-email check -- computed, counted toward the result, but absent from every rendered section -- is now printed, and the rendered set is asserted against a real report so a future check cannot go missing the same way. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
2e0fe059c3 | feat: add display kiosk host | ||
|
|
fc6c8045c4 | feat: add native module realtime transport | ||
|
|
7ff063b87a |
feat: deliver module objects directly to browsers through opaque read URLs
ADR 0030 left exactly one way to get an image to a browser: read the
object into the module's process and write it back out of a module HTTP
route. Every byte of every asset travelled storage -> Ekklesia -> browser.
For the slideshow this platform exists to replace that means Ekklesia in
the path of every image on every display, permanently, for bytes that
never change — and it would make Ekklesia responsible for re-implementing
byte ranges, without which video seeking cannot work at all.
A module can now ask a logical bucket for a temporary browser URL:
const { url, expiresAt } = await assets.createReadUrl("slides/welcome.png");
The contract names intent, not mechanism. There is no signing, expiry
format, endpoint, or bucket in it — a host with no S3 anywhere could
satisfy the same method, which is what keeps a future single-module
standalone host able to implement it. The URL is opaque to module code and
a bearer capability to whoever holds it, so a module authorizes the
request in its own route before asking: storage bounds reach, never
identity. Lifetimes run one minute to six hours, fifteen by default, and
out-of-range values are refused rather than clamped, so "expires forever"
is unreachable. Logical keys stay canonical state; URLs are generated on
demand and never persisted or logged.
The provider endpoint and the browser delivery origin are separated
because measurement showed they cannot be collapsed: an S3-compatible
signature covers the request Host, so a URL signed for one origin and
rewritten to another is rejected. The browser-facing origin is therefore a
signing input (S3_DELIVERY_ENDPOINT), defaulting to S3_ENDPOINT — so the
standard deployment configures nothing and delivery just works. The scheme
is not signed, so a reverse proxy may terminate TLS provided it passes the
original Host through; that is pinned by a test rather than described and
hoped for.
Read URLs are stable within a window of one third of their lifetime.
Signing is a pure function of the clock, so naive re-signing yields a new
URL per second — a guaranteed browser cache miss, and the reference client
keys its <img> by URL, so an unchanged image would be torn down and
re-downloaded continuously. Quantizing the signing timestamp makes
repeated calls byte-identical while leaving at least two-thirds of the
lifetime; Cache-Control is set at signing time to the guaranteed
remainder, because the pinned SeaweedFS image sends none of its own.
Verified against live SeaweedFS: byte ranges answered directly with 206
and a correct Content-Range, so no range proxy is needed in Core; signing
is local (200 URLs in 66ms), so no batch API is needed; unsigned requests
for the same object are refused, so delivery never depends on a public
bucket; tampered signatures, swapped keys, and swapped buckets all
rejected. Signed write and delete URLs are deliberately not built.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
b9ca0d560d |
feat: add host-provided module object storage
`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
baa689b2a7 |
feat: add the module configuration and secrets contract
Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
7729c99e24 |
feat: add the minimal operational module lifecycle
Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
bd34c9c714 |
feat: add Dashboard navigation as presentation configuration
Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
616c1d4834 |
feat: mount discovered module UI in a minimal Ekklesia browser host
Phase 6C pass 2. Every link in the module UI chain existed except the one that closes it: a module could be authored, built, validated, published at a browser URL, and loaded by a generic loader — and nothing in Ekklesia did any of it. There was no browser application at all. Two pieces were missing. A browser could not discover what to mount, and it must not work that out for itself. GET /api/v1/module-ui is the inventory: per publishable module, its id and version, the server-produced artifact URL, the server-produced root of the module's own API, and the subset of that module's declared permissions the caller holds. Five fields, each because building a ModuleUiHostContext needs it — no filesystem paths, and no name/icon/folder/order, because navigation metadata is a design that does not exist yet. Its module set is the ADR 0025 publication registry rather than a rescan, so the inventory and the artifact route cannot disagree. It requires a new Core permission, core.modules.ui.read (baseline authenticated), because it is precisely the enumeration the anonymous artifact route deliberately withholds. Both URLs are server-produced. moduleUiArtifactUrl already existed; moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL shape has exactly one author. Without it the shell would hardcode /api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling, reappearing in the host instead. surfaces/shell (@ekklesia/shell) is the repository's first surface: a small React application built to static files by its own esbuild script. It resolves the principal, fetches the inventory, builds the host context entirely from server-derived data, and hands the artifact URL to @ekklesia/module-ui-host. One frame per selection — keyed by module identity — defeats the stale-load race; the host owns the container, the loading state, and every failure, so a broken module costs its own panel rather than the shell. The shell statically imports no module, and both levels are checked: no source import may name a module, and the emitted bundle must contain exactly one dynamic import (the loader's) with no module id, no /api/v1/modules, no eval, and no blob URL. Permission hints come from Core's own evaluator and stay presentation-only — the end-to-end test has a UI holding no grant call its API anyway and still receive 403. Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts is deliberately excluded, since its barrel pulls Drizzle's types and Node's globals into the browser — the hole ADR 0024 closed one layer down. Not the Dashboard: no navigation, grouping, icons, ordering, design system, or routing. The selector is a list of module ids. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
a02deb9433 |
feat: publish validated module UI artifacts at a platform-owned URL
Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI build pipeline and the generic host loader: Core resolves entrypoints.ui to an absolute server filesystem path, and a browser needs a URL. This adds that publication layer. Core owns the policy. createModuleUiPublicationRegistry builds, from the discovered and started module inventory, the answer to "may this be published, and which exact file is it?" — publishable means the module survived discovery and declares entrypoints.ui, with no new lifecycle state and no installer. apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both segments are lookup keys, not path components. An exact match yields the canonical artifact path discovery already validated; no request value is ever composed into a filesystem path, and the route declares no wildcard segment, so traversal, encoded separators, sibling chunks, package-root files, and source maps are unreachable by construction. An installed module directory never becomes a static file root. The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module route segment, so a module registering GET /ui/:version would otherwise compete with the platform for one URL with routing order deciding the winner. The route requires no authentication, deliberately. Fetching the bundle confers no authority, and a cookie-gated route would break cross-origin import(), which sends none. The cost is stated rather than hidden: a caller who knows a module id and its exact version can download that bundle. The three failure modes return one identical 404 so the route is not an enumeration oracle. Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match handling, not immutable: without an installer nothing stops a version being rebuilt with different bytes. The server reads bytes and writes bytes — never imports, evaluates, or transforms the artifact. Nothing mounts a module UI. A host could now obtain a URL, which was previously impossible; there is still no Dashboard shell, navigation, or metadata endpoint. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
3f9b5e3cb0 |
fix: require a compiled artifact for entrypoints.jobs
Module discovery gated the compiled-ESM rule on an allowlist, `["schema", "server", "ui"]`, so `entrypoints.jobs` accepted `./src/jobs/index.ts`. Every other executable entrypoint required a built `.js` or `.mjs`; `jobs` did not, purely because it was never added to that list. It is the second entrypoint to slip through the same way — `ui` did too, until Phase 6A added it explicitly — which is the argument against the allowlist rather than against `jobs`. The rule is now expressed as "every entrypoint except a directory entrypoint", with `migrations` the only member of that set. A new manifest entrypoint therefore defaults to the stricter validation instead of silently escaping it. The same constant replaces the two existing `=== "migrations"` special cases for the file/directory kind check and its message, so the one thing that distinguishes `migrations` is stated once. The directory message is now templated and produces byte-identical text. Every other entrypoint guarantee is untouched: package-relative paths, no traversal, inside-package before and after symlink resolution, the artifact must exist, and it must be a file. No runtime was added. `entrypoints.jobs` still has no consumer — it is declared, validated, and never executed — exactly as it has been since ADR 0006, and as `ui` was before its host loader existed. The manifest schema is unchanged and this stays within v4: the field, its type, and its optionality are all the same, and only discovery's validation of the declared path tightened. Compatibility impact is nil in first-party code: no module, template, or fixture declares a `jobs` entrypoint at all. A third-party module declaring a source-TypeScript path would now fail discovery, which is the intended correction. Tests cover accepted `.js` and `.mjs`, rejected `.ts`/`.tsx`/`.cjs`, a missing artifact, a directory, a symlink escaping the package, and that discovery still never evaluates the artifact. The three rejection cases were confirmed to fail against the previous implementation. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
44e676f0df |
feat: close the browser boundary and add the generic UI host loader
Two gaps stood between the module UI contract and a host that could use
it. ADR 0023 recorded the first honestly but never diagnosed it: a module
UI file compiled with `"types": []` could still typecheck `process.env`.
The second was simply missing — nothing imported a compiled artifact,
checked its identity, mounted it, or tore it down.
The leak was one type-only import, bisected against the real type graph
rather than guessed. `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing
contracts/identity alone is clean and importing Zod alone is clean, but
contracts exposes a single barrel entry, so that one import reached
module-database, whose Drizzle types re-introduce Node's globals. The
runtime was never affected — the import was erased and host-context.js is
`export {}` — but a type-graph leak is still a real boundary failure,
because it silently permits code that cannot run in its target
environment.
The fix is not a narrower import path. The SDK now has no dependencies at
all, so there is no graph to leak through and no future contracts change
can reopen this. It owns `ModuleUiPrincipal`: the same shape ADR 0022
chose, with the Zod brand dropped. That brand encodes a server-side
parsing guarantee a browser cannot produce, and it is the same reason
`permissions.has` already took a plain string. An Ekklesia host still
passes its own `EkklesiaPrincipal` straight through — a branded string is
assignable to `string` — while a standalone host builds the literal.
Proven the way an author experiences it: two fixtures compiled under the
exact project a module uses, one supported and one deliberately reaching
for `process`, with the second required to fail on that specific name.
`@ekklesia/module-ui-host` is the generic loader, in its own package
because the SDK is bundled into every module artifact and host-only
loader code has no business shipping there. The boundary checker enforces
both directions: modules may not depend on the loader, and the loader may
depend only on the SDK plus dev-time tooling.
The distinction that shaped the design: Core resolves `entrypoints.ui` to
an absolute path on the server's disk, and a browser needs a URL. A
loader that accepted the path would test green on a server and fail in
every real host, and would quietly own asset publication. So the loader
takes a browser-loadable URL and knows nothing about how one is obtained.
Ekklesia has no route that publishes installed artifacts, so this is
precisely: loader complete, publication deferred. Nothing can mount an
installed module UI yet, and the docs say so rather than implying
otherwise.
Dynamic import is `(url) => import(url)` with a non-analyzable specifier,
kept as a runtime import so a host does not statically bundle every
installed module or need a hardcoded registry. It is injectable for
bundlers needing their own escape hatch, and deliberately not `eval` or
`new Function`, which would cost a host `unsafe-eval` in its CSP.
Identity is validated through the SDK's existing parser before `mount`
runs, so a stale or mismatched bundle never renders. Contract violations
propagate unwrapped; the loader adds only import and mount failure codes,
sanitized, with the cause attached. Unmount is idempotent, safe when a
module returned no cleanup, and marks an instance unmounted before
running cleanup so a throwing cleanup surfaces once and never re-runs. A
failed mount registers no cleanup. Each instance owns its own state, so
two mounts coexist. The loader never mutates the host's container — not
on success, failure, or unmount.
Tests prove all of it against real module URLs (`data:`, which both
browsers and Node import natively) rather than filesystem paths,
including one artifact built by the canonical Pass 2 pipeline and run
under both an Ekklesia-shaped and a standalone-shaped context. Core,
apps/api, and the contracts package are untouched; discovery remains
inert.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
1e5866504e |
feat: build module UI into its declared browser artifact
ADR 0022 defined what a module UI is and selected React and TypeScript as the authoring model, but closed by recording that it was not buildable end to end: `browser.json` preserves JSX, bundler plugins are denied, and nothing turned a module's `.tsx` into the artifact its manifest declares. This closes exactly that gap. esbuild produces the artifact. It was already the right answer before it was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`, so the pinned dependency adds no second copy and no new trust decision. It is also the smallest tool that covers TSX, ESM, browser platform, bundling, source maps, and a define-time constant with no plugin ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which the module boundary denies on purpose; `tsc` alone cannot bundle, so its output would carry bare `react/jsx-runtime` imports no browser resolves. React is bundled, not externalized. That follows from ADR 0022 rather than from convenience: `mount()` was made the ABI precisely so React would not be part of the host contract, and externalizing it would reverse that through the build system — every host would have to supply a compatible React, and a standalone host might not use React at all. The cost is real and accepted: duplicate runtimes and a ~570 KB unminified artifact. Sharing one runtime later is a build optimization that cannot change the contract, because the contract is a mount function. Output is browser ESM at `es2022`, unminified so an operator can review what they install, with `process.env.NODE_ENV` substituted — React reads it at module scope and would otherwise touch a Node global that does not exist in a browser. The output path is read from the module's own manifest rather than a build convention, so declared and produced artifacts cannot drift, and it is validated with the same `packageRelativePathSchema` discovery enforces, so a build can never write where discovery would later reject. `@ekklesia/module-ui-build` owns every option; modules own none. It uses the existing `tooling` role that modules could already depend on, so this pass required no boundary relaxation at all. Its `bin` points at a committed launcher rather than compiled output because pnpm links bins during install, before Turborepo builds anything — a bin targeting `dist/cli.js` fails to link on a fresh clone and breaks every module's UI build in CI. A module with no `entrypoints.ui` is not an error; the CLI reports nothing to build and exits zero, so one script serves both kinds of module. Styling is React inline style objects. A stylesheet import fails the build rather than emitting a sidecar `.css` that no manifest declares and no host loads — an undeclared asset would appear to work in tests and silently do nothing in a real host. `templates/module` is the canonical example and now ships optional UI. Its tests build the artifact from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered output, and unmount it; the artifact is rebuilt per run so the proof can never pass against stale output, and `dist/` stays gitignored. Because verify runs both `pnpm test` and `pnpm build`, a UI that cannot be produced fails CI through two independent paths. One correction worth stating plainly: the UI TypeScript project declares `"types": []`, and a UI file referencing `process` does fail in isolation, but TypeScript still pulls Node globals in transitively — a file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts` and through its Zod and Drizzle types `process.env` typechecks again. The docs and ADR describe this as the convention it is rather than the guard it is not. Core is untouched. Building is an authoring step; discovery still only validates the artifact a module shipped and never executes or produces one. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
8d651c427d |
feat: define the module UI authoring model and host contract
ADR 0021 established that a module may own UI and added a statically
validated, never-executed `entrypoints.ui` path, but deliberately left
five questions open: what the artifact exports, what a host provides,
how one artifact runs under both Ekklesia and a standalone host, what
the browser-side trust model is, and how module UI avoids being welded
to a Dashboard that does not exist. This answers them.
React and TypeScript are the supported module UI authoring model. The
repository already leaned this way: `packages/typescript-config` ships a
`browser.json` preset and a `react-native.json` preset with
`"jsx": "react-jsx"`, and ADR 0002 names browser and React Native
configurations. One framework is supported; every other stays rejected.
React is deliberately not part of the contract. A module's compiled
artifact default-exports `defineModuleUi({ id, version, mount })`, and
`mount(container, context)` takes a DOM element and returns an unmount
function. `id` and `version` are build-artifact identity markers a host
verifies against the manifest, mirroring `defineModuleRuntime` exactly.
A host needs an element and a context, never a matching rendering
library, so a module's framework, a host's framework, and the platform
boundary between them stay independent.
The contract lives in a new `@ekklesia/module-ui-sdk` rather than in
`@ekklesia/module-sdk`, for a concrete reason: that package re-exports
all of `@ekklesia/contracts`, so routing module UI through it would drag
Zod, Drizzle, and semver into every browser bundle. The new package has
no runtime dependencies, and its one dependency on contracts is
type-only — the emitted `host-context.js` is `export {};`, and a test
pins that so it cannot regress.
The host context is four members: `moduleId`, `api`, `principal`, and
`permissions`. Navigation, theme, module configuration, organization
context, and diagnostics are deliberately absent; each needs a design
that does not exist. Module UI reaches its backend through
`api.request(path)` with a path relative to its own API root and never
constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private
deployment detail — a module that builds that URL is silently unhostable
anywhere else. One fixture artifact is mounted under two hosts with
different origins and URL shapes to prove it.
Client-side permission hints are presentation only, and both the docs
and a test say so plainly: a denied hint does not stop the module from
calling its API, because server-side `requirePermission` is the only
authority. Module UI is trusted code in the host's own browser realm —
no iframe, no sandbox — which matches the backend model rather than
overstating it, since a module's server half already holds the API's
database authority.
The boundary guard is narrowed precisely, not removed: `.tsx`, `react`,
and `react-dom` are un-denied and modules may depend on the new
`module-ui-sdk` role. `.jsx`, `.css`, every other framework,
`react-router-dom`, `react-native`, and `@vitejs/plugin-react` stay
denied, layer rules are untouched, and an unsupported framework aliased
behind an allowed name (`"react": "npm:vue@3"`) is resolved and rejected
by its real target. Fixtures prove each case, including that module UI
source still cannot import Core.
Core, apps/api, and the manifest contract are untouched. Discovery still
validates the `entrypoints.ui` path and never evaluates it; hosts
perform their own dynamic import and validate the result with
`parseModuleUiExport`. No Dashboard shell, UI runtime, navigation,
grouping, standalone host, build or styling pipeline, installer,
hot-loading, or feature module is added.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
44ba259f58 |
feat: establish the module product contract
Ekklesia's module model has been backend-only since ADR 0001: a module owns domain behavior and data, and every presentation concern belongs to a surface. That prevents a module author — the person who knows the domain — from shipping the screen that makes their module useful, and it guarantees a module and its own UI drift apart. It also pushes toward two codebases for one domain the moment a module needs to run standalone. ADR 0021 replaces that rule. A module is an independently versioned product that owns its domain behavior, data, permissions, routes, migrations, and its own UI, hostable inside Ekklesia and — from the same implementation — by a later standalone host. Core keeps the containing application shell and every platform policy: authentication, authorization enforcement, platform context, top-level routing, navigation, global theme, error boundaries, and trusted-origin/CORS. The module graph stays flat: modules are peers, genuine module and capability dependencies remain the only relationships, and Dashboard folders are presentation configuration an administrator can reorganize, never module architecture. This pass implements the static contract only. `manifestVersion` moves to 4 and `entrypoints` gains one optional field, `ui`. Version 3's `entrypoints` is strict and rejects `ui`, so this is an incompatible shape change and version 3 is now rejected outright rather than reinterpreted, exactly as version 1 was by version 2 and version 2 by version 3. Every field version 4 adds is optional, so upgrading a backend-only manifest is a one-character edit. Discovery resolves and validates `ui` under the same rules as `server` and `schema` — package-relative, inside the package before and after symlink resolution, a real file, compiled ESM `.js`/`.mjs`, so source TypeScript is rejected rather than assumed to be compiled later. Discovery stays inert: nothing imports, evaluates, mounts, or renders a UI entrypoint, and a test proves a throwing artifact still discovers successfully. This follows `entrypoints.jobs`, declared and resolved since ADR 0006 with no runtime consumer. The name is `ui`, not `dashboard`, because a module's UI must be hostable by Ekklesia's shell and by a standalone host, so the contract cannot be named after one of them; and not `surface`, because `surfaces/*` is a separate ownership layer. No widget, route, icon, folder, menu, layout, or standalone-metadata field is added, and tests assert the manifest still rejects every unknown entrypoint key and offers no hierarchy field. Deliberately unchanged: `scripts/check-boundaries.ts` still enforces `modules-do-not-own-ui`. Relaxing that guard depends entirely on the UI authoring model a later pass chooses, and weakening a working protection before that decision exists would trade real coverage for nothing. A module may therefore declare `ui` today only for a framework-neutral compiled artifact, and the docs say so rather than implying otherwise. Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model are untouched — a module that ships UI is exactly as trusted as one that does not. No Dashboard shell, UI runtime, renderer, framework choice, navigation registry, grouping system, standalone host, installer, hot-loading, or feature module is added by this pass. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
07a3c61083 |
docs: define deployment trust boundaries
One canonical security document, linked from the README, platform architecture, and module-development guide, stating what the boundaries actually protect against — and, more importantly, what they do not. Installed modules are trusted compiled backend code. A module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role and pool, so `ModuleDatabase.execute` gives it the API's full database authority over every table, including identity, grants, owner, bootstrap, and audit. Table prefixes, manifests, permission declarations, route validation, frozen contexts, typed facades, and the capability registry are correctness and coordination boundaries that assume a cooperative module; none of them constrains one that is not. There is no process, container, syscall, filesystem, network, database-role, schema, tenant, or resource sandbox between a module and Core, and the document says so in those words, along with what would have to be built before untrusted modules could be accepted. It also records the honest deployment state: Ekklesia is not deployed. There is no production instance, reverse proxy, public listener, or deployment automation anywhere in this repository, and no `.env` has ever been committed. The loopback listen default and the production signup refusal are real, but a config default is not a firewall, so the origin-reachability, proxy-overwrite, header-spoofing, and trusted-peer properties a public launch needs are written as a launch-blocking checklist rather than as guarantees. The per-process authentication limiter is described as exactly that, valid only while signup stays disabled and exactly one instance serves traffic. The CI section records the workflow's verified posture and deliberately stops there: a pull request can edit `runs-on`, so a workflow file cannot be the boundary, and the runner topology is not described as though it had been verified. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
490b85b07c |
fix: bound module lifecycle shutdown
Nothing bounded shutdown, and a module that failed to start was never cleaned up. `startModules` appended to its rollback set only after `start()` resolved, so a hook that acquired a connection, registered a listener, or recorded partial initialization and then threw had its own `stop()` skipped, while every previously completed module was rolled back correctly. A module now joins the rollback set the moment its start hook is invoked: its own stop hook runs first, then earlier modules in reverse order. The original start issue stays first in the reported issues, and a failure stopping the failing module is appended without preventing the rest of the rollback. The consequence for module authors is explicit and documented — `stop()` may run after a partially completed `start()` and must be safe and idempotent about resources it may or may not hold. Neither stage of shutdown had a deadline. `shutdown()` awaited `server.close()` with no bound, so one open connection could hold it forever, and each stop or rollback hook was awaited without a bound, so a hook that never settled kept the pool open indefinitely. Both now have fixed, non-configurable defaults: 10 seconds per module hook and 30 seconds for the graceful HTTP drain. A hook that reaches its deadline records MODULE_STOP_TIMEOUT or MODULE_ROLLBACK_TIMEOUT and the sequence continues with the next module, so the whole stop sequence is finite in the started module count rather than one global ten-second bound. Timers that lose the race are cleared, the losing promise gets a permanent rejection handler so a late throw cannot become an unhandled rejection, and a timed-out hook is never awaited again. At the drain deadline the platform closes idle and then active connections through Node's own public closeIdleConnections/closeAllConnections, after server.close(), touching no private field, and stops waiting for the close callback. It then runs the bounded module stop sequence and closes the pool, so a drain timeout or a stop failure never skips later cleanup. Both are reported together as an ApiShutdownError carrying only fixed booleans and the already sanitized module error; a forced drain is a shutdown failure, not a success. Stated honestly rather than overstated: a deadline bounds how long the platform waits, not how long module code runs. An in-process promise cannot be canceled, and closing a socket does not prove a handler promise ended. What is guaranteed is that shutdown stops waiting and the pool closes afterwards, so late database work fails. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
800ecfdb92 |
fix: reject ambiguous module routes
A module can register both `GET /events/:id` and `GET /events/new`. Driven through the pinned router, `/events/new` reached the parameter handler under `demo.events.read` when the parameter route registered first, and the static handler under `demo.events.create` when it registered second. Nothing rejected the pair, so declaration order decided which permission guarded the path. That is a permission policy, not a routing preference, and it must not be inferred from array position. Two routes of one module now overlap when they share a method and a segment count and, at every position, either both segments are the same static literal or at least one is a parameter. Under this grammar — static literals or `:name` parameters, no wildcard, regex, optional, or catch-all — that is exactly "some concrete request path matches both". `moduleRoutesOverlap` implements it in the contracts package, where path semantics already live and Hono cannot reach. Core checks it during staged registration, after the existing duplicate check, so identical shapes (including paths differing only in parameter name) keep returning MODULE_ROUTE_DUPLICATE and a genuine intersection returns the new MODULE_ROUTE_OVERLAP. The rejected module publishes none of its staged routes or capabilities, no start hook runs, and no listener binds. Rejection rather than a specificity rule: ranking static above parameter would still silently pick which permission applies. Disjoint routes keep their order; different methods, different segment counts, and the same local path in two different modules (each has its own platform-owned mount prefix) all remain legal. The same requests also pinned down what handlers actually receive. Route declarations are validated; route *parameters* are not, and the two are unrelated. A single encoded segment arrives decoded as `/../admin`, `a\b`, an embedded NUL, CR/LF, `1; drop`, or non-ASCII text — none of which any declared path could contain. The contract now says so explicitly: decoded values are untrusted, module-owned input that a handler must validate against its own exact domain grammar before any database, filesystem, command, or identifier use, and re-encoding is not a substitute. No generic validator, path-normalization layer, or route field was added, because module parameters are module-owned domain types and one platform-wide pattern would either reject legitimate values or approve meaningless ones. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
d3c6bd73d3 |
fix: enforce API request limits
Add a single platform-owned request-body policy (hono/body-limit) with small, named limits for Better Auth and Core's grant-mutation endpoint, and a new required maxBodyBytes field on the executable module route contract so every module route makes a conscious no-body-or-bounded-body choice, capped at a platform ceiling. Every oversized request gets one sanitized 413 contract and never reaches business logic, authorization, or a handler. Also configure finite Node header/request/keep-alive timeouts, including the non-obvious connectionsCheckingInterval needed for them to take effect promptly, so a slow-header or slow-body connection cannot hold a socket indefinitely. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
c38b572778 |
docs: document module database access
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015 where their original text is now inaccurate. Sweep docs/development/api.md, docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md, and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness; correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching gap in README's current-status narrative. Extend the module template with a permissioned GET /records route reading through context.services.database, using the canonical createModuleTable helper, with matching template tests. |
||
|
|
dfaf324a65 |
feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement, and module-runtime lifecycle integration. apps/api now discovers the installed module inventory exactly once (prepareStartupModuleInventory), composes the platform permission registry from it, starts the module runtime from that same discovered list (startDiscoveredModuleRuntime, never rediscovering), and mounts StartedModuleRuntime's published routes under /api/v1/modules/<module-id> via a new apps/api-owned Hono adapter (module-http-routes.ts). Every module route is protected by the existing requirePermission middleware against the composed registry, state-changing methods additionally require a trusted Origin, OPTIONS remains entirely platform-owned, and a handler that throws, rejects, or returns a non-Response becomes a fixed, sanitized 500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped and every module response is forced to Cache-Control: no-store with Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime between draining the HTTP server and closing the shared pool, and every startup failure path stops the runtime and/or closes the pool before propagating, without masking the original failure. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
e296e083ec |
feat: define module HTTP routes
Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical path grammar, route definition/context/handler types, and validation) and Core's module runtime staging/publication of a module's declared routes alongside its capabilities, gated on the module's own manifest permissions. Routes are collected into StartedModuleRuntime as an immutable snapshot but are not yet mounted into apps/api; that is pass 2. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
edd7148aa8 |
feat: integrate module authorization
Wires composePlatformPermissionRegistry into apps/api's production startup: startApi() discovers every installed module, composes the one immutable platform permission registry (Core's own declarations plus every module's), and only then constructs the shared PostgreSQL pool, Better Auth, and the HTTP server. A discovery or composition failure — an invalid manifest, a reserved core.* namespace claim, or a duplicate declaration — propagates before anything exists to close; there is no Core-only registry fallback. apps/api/src/platform-permissions.ts resolves the repository root from its own file location, never process.cwd(), so package-scoped test runs discover the right modules/ directory. createApiApp/createGrantsRoutes now require the composed registry as an injected authorization option, replacing the previous hard-coded CORE_PERMISSION_REGISTRY; requirePermission continues to work identically for Core-owned and module-owned permissions, since it has no concept of the distinction. Extends pnpm doctor with a modules.permissions check (reusing the already-discovered module list, distinguishing reserved-namespace from generic-duplicate failures, never re-discovering modules) and pnpm modules:validate to compose the same registry before a module is considered installable. Adds focused coverage: startup-ordering and failure-isolation tests in apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement tests in apps/api/tests/authorization-middleware.test.ts, Doctor composition tests in core/tests/doctor.test.ts, and a live PostgreSQL end-to-end scenario proving a real installed module's permission is discoverable, grantable, and deniable once the module is absent again after a restart, without ever executing its server entrypoint. |
||
|
|
d9dd409aa3 |
feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract (packages/contracts/src/authorization.ts) — id/baseline/description, strict, unknown-property-rejecting — consumed by both Core's authorization registry and module manifests, replacing Core's previously separate interface. Bumps the canonical module manifest to manifestVersion 3: permissions become complete declarations instead of bare identifier strings, with manifest-owned namespace and duplicate-id enforcement. Version 2 and bare string-array permissions are rejected outright; there is no compatibility adapter. Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts), a pure, synchronous composer that builds one platform permission registry from Core's own declarations plus every already-discovered module's, in deterministic order, reusing createPermissionRegistry and rejecting any module-contributed permission in the reserved core.* namespace. core/src/authorization remains independent of core/src/modules; only the module integration layer depends on authorization. Updates the module template, module-development docs, authorization docs, and existing manifest fixtures for manifestVersion 3. Does not wire the combined registry into apps/api — that is pass 2. |
||
|
|
02ee3ccd80 | feat: add Ekklesia doctor command | ||
|
|
831a48187c | test: add PostgreSQL migration integration coverage | ||
|
|
0b282865cb | feat: add transactional module migration runner | ||
|
|
88c28d75c9 | feat: add module migration contracts and validation | ||
|
|
80b56b72b1 | feat: add module schema discovery and validation | ||
|
|
7b448d8706 | feat: add module runtime lifecycle | ||
|
|
57552a21a4 | feat: add module contracts and discovery |