6db50516b7c073526590a4b41ee5fa60880fceea
70
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6db50516b7 |
feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050)
CI / verify (pull_request) Successful in 59s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Implements the first ADR 0050 slice: Core's ORM-neutral executor and its
PostgreSQL evidence. Nothing is wired to modules yet; manifest v8, SDK
adapters, schema metadata, packaging, and Signage are later slices. The
legacy ModuleDatabase facade is unchanged.
Contracts (@ekklesia/contracts, no imports at all):
- ABI version 1; query { text, values, rowMode } returning
{ command, rowCount, rows, fields: [{ name, typeOid }] };
- executor, transaction, and savepoint types with exactly the ABI's
members; parameter and result value types;
- ModuleDatabaseError { code, sqlState? }: eight stable codes, and a
SQLSTATE only when PostgreSQL supplied one; static messages, no cause,
frozen; isModuleDatabaseError recognises it by shape.
Core (createModuleDatabaseExecutor over the API's existing pool):
- one statement per query, enforced by forcing the extended query
protocol so PostgreSQL itself refuses a second statement (42601);
no SQL parsing;
- parameters: null, boolean, number, bigint, string, Uint8Array, and
rectangular arrays of them; everything else refused before sending;
- a Core-owned decoding table that never consults pg's global parser
registry: int8, numeric, temporal, and JSON as exact text; bytea as a
plain Uint8Array; built-in arrays element by element;
- every operation pins TimeZone, DateStyle, IntervalStyle, bytea_output,
and extra_float_digits, then resets with DISCARD ALL before returning
the connection, or destroys it when it cannot be reset cleanly;
- callback-scoped transactions with explicit isolation and access mode;
a COMMIT that PostgreSQL answers with ROLLBACK is reported as failed;
- savepoints named by Core; only the innermost scope is active; scopes
are revoked when their callback settles;
- a callback that settles with work outstanding is rolled back, never
committed or released;
- transaction-control statements sent through query are refused by
command tag and transaction status, and fail the transaction;
- the module ID is carried for Core's attribution only.
Tests:
- 83 integration tests on a database with hostile defaults: queries,
the one-statement rule, parameters, decoding, errors, transactions,
savepoints, forgotten awaits, pool safety after every failure class,
and session state;
- unit tests for encoding, decoding, options, sanitization, and scope
bookkeeping;
- type-level and source checks that no ORM or driver object crosses
the boundary.
ADR 0050 is Accepted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
||
|
|
11e2495484 |
docs: ADR 0050, tighten transaction scope, single-statement, and error rules
CI / verify (pull_request) Successful in 56s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Three narrow clarifications to the proposed contract. No other decision changes. - Outstanding work: module code awaits every operation before its transaction or savepoint callback returns. A callback that settles with work outstanding is a scope-usage error and never commits. Core may let started operations settle before rolling back and releasing the connection, but waiting never turns into a commit. Executors stay revoked once their scope has finished. - One statement per query() call: a correctness and API rule, not a security boundary. It is upheld by a reliable driver or protocol mechanism, never a home-grown SQL parser. An implementation that cannot uphold it stops and reports instead of weakening the ADR. - Errors: `code` is a stable Ekklesia-owned category and always present. `sqlState` is optional, present only when PostgreSQL supplied one. ABI-detected failures (closed scope, invalid parameter, invalid option) carry a code and no SQLSTATE. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
759872834d |
docs: propose ADR 0050, Module Database ABI v1
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
Core and modules exchange PostgreSQL SQL text, bound parameters, result
rows with field metadata, and Core-owned transaction scopes. Never ORM
objects. A module may use Drizzle, Kysely, raw SQL, or another
module-side builder. Core's own ORM becomes an implementation detail.
ADR 0050 decides:
- an explicit correctness/compatibility boundary, not a security
sandbox: no claim for SQL parsing, execution-time prefix checks,
SET ROLE, or PostgreSQL roles;
- opt-in through manifest version 8 `database.abi: 1`, a hard cutover
like every manifest version; a version 8 module without `database`
keeps the legacy ModuleDatabase facade during the deprecation window;
- the executor: query({ text, values, rowMode }) returning
{ command, rowCount, rows, fields }, one executor per module;
- a small parameter set (no Date, no implicit JSON, no identifiers);
- a Core-owned decoding table that loses no precision and does not
depend on time zones (int8, numeric, temporal, and JSON as text;
bytea as Uint8Array);
- a sanitized ModuleDatabaseError { code, sqlState? }, recognised
structurally;
- callback-scoped transactions and Core-named savepoints, revoked when
their callback settles;
- module-side adapters (Drizzle via pg-proxy, raw SQL, later Kysely);
- plain schema metadata for ABI v1 modules instead of Drizzle objects;
- migrations unchanged; packageFormat stays 1; ABI v1 packages omit
transitional.databaseRuntime.
It partly supersedes ADR 0016 and amends ADR 0007 for ABI v1 modules,
and extends ADR 0049. Status Proposed. Accepted ADRs are unchanged. No
runtime, SDK, manifest, packaging, migration, or Signage changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
||
|
|
35ef31df9f |
feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
A separately versioned module is now distributed as one deterministic .tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by Core before anything is installed. This slice implements the portable `any` target, with the module template as the end-to-end proof. ADR 0049 is accepted. @ekklesia/contracts: the Module Package v1 contract. The strict ekklesia.package.json schema (packageFormat 1, identity, target, runtime, provenance, incorporated Ekklesia packages, strict transitional facts, an exact byte-wise-sorted file list with size and SHA-256), target names, and the generated runtime package.json. @ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir> --target any --out <dir> --source-revision <commit>`. - Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle included; only Node built-ins remain imports. Any warning, unresolved import, external dependency, or native or platform-specific package fails. - Copies browser artifacts as built, after proving them current, and the migrations; generates package.json and ekklesia.package.json; never ships src, tests, declarations, source maps, or workspace ranges. - THIRD_PARTY_NOTICES: every third-party package traced from the bundles' own inputs, credited once with its declared licence and every licence, copying, copyright, and notice file it ships. A missing licence fails; a missing text fails unless its standard text is complete without copyright lines (Apache-2.0, vendored, needed by drizzle-orm). - Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime 0, 0755/0644, no links. Byte-identical across runs and machines. - Writes only the archive and its .sha256; never deploys, links, or installs, so the workspace's install state is untouched. Core: verifyModulePackage and `verify-module-package.js <archive> [--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe or escaping paths, and a second top-level directory; checks metadata, every file, identity, target, Node.js range, SDK, and the transitional Drizzle agreement; runs the platform's module and migration discovery; loads the backend once in a forked process with an empty environment; only then extracts, with normalized modes, into a new directory. Errors are sanitized. @ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs and whether the built file is current. The nested stylesheet build now works from the module directory, so artifacts no longer depend on the caller's working directory. The split deploy smoke builds the package with the canonical packager (scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm deploy packaging), verifies it with the same generic image, installs the verified tree, removes it, and reinstalls the exact same archive. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
1119cc5fdf |
docs: propose ADR 0049, Ekklesia Module Package v1
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m41s
A separately versioned module is distributed as one immutable, self-contained, deterministic .tar.gz with a separately distributed SHA-256. The package contract is independent of hosting, registry, transport, and marketplace. ADR 0049 extends ADR 0048 with: - the package layout: one top-level module directory with a generated runtime package.json, the unchanged manifest, ekklesia.package.json, bundled backend entrypoints, built browser artifacts, migrations, THIRD_PARTY_NOTICES, and node_modules only for native dependencies; - backend bundling of all pure-JavaScript dependencies, with externals decided and validated by the canonical packager, never by module authors; - targets: `any`, or <platform>-<arch>[-<libc>] (linux-x64-glibc for Signage); - ekklesia.package.json (packageFormat 1): id, version, target, runtime needs, provenance, incorporated SDK versions, a `transitional` Drizzle requirement explicitly not the Module Database ABI, and per-file size and SHA-256; - archive safety: no links of any kind, no absolute or `..` paths, no special files or setuid/setgid; extraction normalizes ownership and modes; - the intended installation lifecycle and its rollback limit: code-only rollback only before new migrations apply, a backup restore after; - integrity, not authenticity; signing and trust roots deferred; - pnpm deploy explicitly not the packaging mechanism. Status Proposed, following the repository's usual lifecycle. ADR 0047 gains a cross-reference to consume canonical packages. The module development guide no longer recommends running pnpm deploy in a workspace, which corrupts that workspace's install state. No runtime, tooling, Docker, CI, Signage, or instance changes. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
e36b70d8d4 |
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The production image now contains zero feature modules and is the same for every installation. Installed modules live in module storage that one Core resolver selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an absolute path that must exist when set (/var/lib/ekklesia/modules in the image). A relative, blank, missing, or unreadable configured directory fails closed with a sanitized error that never contains the value. - Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply and validate, module validation, module enablement, and Doctor all resolve it the same way; the test-only API override still takes precedence. - Doctor receives Core's migrations directory explicitly instead of deriving it from the modules directory's parent. - The migration runner retains and reports the applied history of a module that is not installed, and plans and applies Core's and present modules' migrations around it. Core's history and every installed module's history stay strict: a missing or changed applied migration, or a downgrade, still fails. No down migrations, no data deletion. - deploy/Dockerfile copies no module and installs nothing for the module template; .dockerignore excludes modules/ and the build refuses a context that contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and sets EKKLESIA_MODULES_DIR. - deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api and migrate, with create_host_path disabled. - scripts/check-image.sh proves the image holds no modules and that a populated modules/ directory leaves its runtime file system identical; CI runs it. - The split deploy smoke now uses the same generic image: it boots with no modules, installs a self-contained test module packaged from the template (test-only), migrates and restarts, removes it (Core runs, planning reports the retained history, data survives), and restores it with its data. Docs: deploy README (module storage, install/update/remove/restore, the runtime modules target), .env.example, API configuration, module development guide, testing, workflow, security, and the architecture overview. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
cefdde36a2 |
docs: accept ADR 0048, one generic image with installed modules outside it
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 21s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m3s
Ekklesia produces one platform image with zero feature modules. Installed modules live in installation-owned storage selected by EKKLESIA_MODULES_DIR (/var/lib/ekklesia/modules in the image, <repo>/modules in development), mounted read-only and discovered only at a controlled restart. An installed module directory is self-contained; the platform lends it no dependencies. Uninstall is distinct from disable and purge: removing module code keeps its data and stored state, and applied migration history of an absent module is retained and reported instead of blocking planning. Present modules and Core stay strictly validated. Supersedes in part ADR 0044 (composition from pinned sources) and ADR 0009 (rejection of history absent from the inventory); amends ADR 0006; extends ADR 0028 and ADR 0030. ADR 0047 gains a cross-reference saying it must be revised before acceptance, and deploy/README.md notes that its composition model is being replaced. No code, Dockerfile, Compose, CI, or test changes. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
0ec1755279 |
docs: the server runs Gitea 1.27.2, not 1.24.2; say what it actually honours
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m1s
The CI documentation, the workflow header, the README, ADR 0047 and the sweep's comment all said the server was Gitea 1.24.2, which ignores `permissions`, `concurrency` and `timeout-minutes`. The server reports 1.27.2 (GET /api/v1/version). Each claim is now stated from what is verified on this server: - `concurrency` is honoured, observed: Ekklesia runs 133 and 135 were cancelled when a newer run started for the same pull request; - `permissions` is supported by 1.27 with Gitea's own scopes, but the token's actual authority is not visible through the API and not yet proven, so nothing claims the job token is read-only; - `timeout-minutes` is no longer listed as unsupported, but is not verified, and teardown still relies on the stack sweep and a reaper. Requirement 1 for executing untrusted contributions (upgrade to the 1.26 line or later) is marked done; items 2 and 3, configuring and proving token authority, remain. ADR 0047 (still Proposed) restates its "docker run, not a reusable workflow" reasons for 1.27, where private sources are supported. The reasons still hold without the 1.24 argument. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
3a2f5d68f6 |
docs: ADR 0047, nothing the harness starts outlives its job
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 57s
A job the runner kills never runs its own cleanup trap, and the platform's CI leaked a whole migration stack that way. The harness follows the platform's three-layer rule: - everything it starts is named and labeled as a CI stack; - it cleans up in a trap; - the template workflow sweeps stale CI stacks before running it, and removes its own in a final if: always() step. The sweep ships in the harness image. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
3827841205 |
docs: propose ADR 0047, a published harness every module runs in CI
CI / verify (pull_request) Successful in 53s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Canceled after 18s
Signage reached production with three defects that appear only inside Ekklesia in a real browser, and no CI that ran it there. The ADR proposes: - a versioned harness image, ekklesia-module-harness:<platform version>, built and pushed by the platform's release CI; - what the harness does: assemble a module into that platform, validate and build it, migrate a disposable database, start the split reference deployment, and mount every declared UI surface in headless Chromium; - what fails it: console errors, unexpected 4xx/5xx, unhandled rejections, a display soak, and generic no-dead-ends injections; - testing at the lowest and newest platform versions of a module's range; - extra smoke steps in ekklesia.harness.json, beside the manifest rather than in it; - a later data-access and egress check against declarations; - a short template workflow that calls the harness by digest with one `docker run`. Not a reusable workflow: thinly supported on Gitea 1.24, and private sources only arrive in 1.26. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
f63f80f2f0 |
feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
8ea4d4d2e4 |
docs: propose ADR 0046, installation-owned branding
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m40s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (pull_request) Successful in 48s
Records a core principle: Ekklesia's branding is never forced on an installation. Anything a member, visitor, or volunteer can see (titles, sign-in and auth screens, emails, display and offline screens, error messages, app names and icons, attribution) is configurable and removable entirely. With no name configured, wording is neutral, so the default names nothing and the rule is testable. Internal identifiers, operator tooling, and developer diagnostics are exempt. Modules follow the same rule. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
55accd0e65 |
docs: state exactly what ADR 0045's browser smoke proves
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (pull_request) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
a56a853319 |
docs: propose ADR 0045, split Dashboard and API origins
Records how an installation runs the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image: the API origin reaches the Dashboard at run time in a meta element, the display host stays on the API's origin (device cookies and the offline code cache depend on it), the API trusts its own origin as Better Auth already does, cookies stay host-only, CORS stays narrow and becomes part of the module UI contract, the reference proxy gains per-hostname routing, and the deploy smoke test proves both topologies, the split one in a real browser. Amends ADR 0034 and extends ADR 0044. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
7cbe4e1b2c |
chore(release): prepare 0.1.1 and accept ADR 0044
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m46s
CI / image-build (pull_request) Successful in 25s
CI / deploy-smoke (pull_request) Successful in 41s
Bump the root and every platform workspace package from 0.1.0 to 0.1.1, so the platform reports the version the v0.1.1 tag will carry. Module discovery checks compatibility.platform against the root version and compatibility.moduleSdk against @ekklesia/module-sdk; both stay inside every existing ^0.1.0 range, so no module needs a new range. Everything since v0.1.0 is additive (health endpoints, Dashboard serving, production dependency declarations, the deployment image and Compose project). templates/module keeps 0.1.0: it is a module, versioned on its own, and its manifest, source, and tests carry its own product version. Mark ADR 0044 Accepted. Slices 1-4 are implemented and proven by CI's image build and deploy smoke; slice 6 (scheduled backups and the restore procedure) remains its open item. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
167b4c4f14 |
feat(deploy): add the reference deployment Compose project
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 27s
CI / deploy-smoke (pull_request) Successful in 42s
Implement ADR 0044's reference deployment as a base Compose file plus overlays, so an installation that already runs a proxy can use the base alone. - compose.yaml: api, postgres, object-storage, and the operator-only migrate (profile ops, plan by default). No published port. api and migrate run init, read-only, tmpfs /tmp, cap_drop ALL, no-new-privileges. postgres is only on the internal data network. - TLS everywhere from one installation-private CA: PostgreSQL TLS 1.3 only, with pg_hba rejecting every non-TLS connection, and the API on verify-full via EKKLESIA_DATABASE_CA (PEM text). SeaweedFS serves S3 over HTTPS (ADR 0030 requires https in production), trusted by the API through NODE_EXTRA_CA_CERTS. No Core change. - compose.proxy.yaml + Caddyfile.example: Caddy pinned by digest, ACME HTTP-01 (DNS-01 documented), routes /, /api/*, /display/*, /assets/* to api and refuses everything else; the storage hostname preserves Host. The API trusts exactly the proxy's fixed /32 on an edge network whose dynamic range excludes the fixed addresses; Caddy overwrites X-Forwarded-For and strips caller-supplied forwarding headers. - compose.tunnel.yaml: cloudflared pinned by digest, token from its own env file; Caddy honors CF-Connecting-IP only from its fixed address. - Four per-service root-owned env files, examples with no values; scripts/init-tls.sh (idempotent, never overwrites a key) and scripts/gen-secrets.sh (prints only). - CI: pnpm deploy:config in ci:verify for all three combinations, and a deploy-smoke job (scripts/smoke-deploy.sh) proving readiness, the Dashboard, signed storage delivery, the client-address chain, initial owner sign-in/out, verify-full database TLS, and Doctor through the proxy, against disposable services. tests/deploy.test.ts pins the structural guarantees. - ADR 0044 (still Proposed) amended to match: API-to-storage HTTPS, DNS-01 needs a custom Caddy build and tunnel-only needs DNS-01, the tunnel's origin TLS settings live in the Cloudflare dashboard, port 80 serves HTTP-01, and secrets are one env file per service. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
fc422b6d8d |
fix(packages): declare runtime imports as production dependencies
@ekklesia/module-sdk imports drizzle-orm at run time but declared it only as a peer and a development dependency. A workspace package is linked into its consumers as a directory, so its imports resolve from its own node_modules, where a consumer never satisfies a peer; a production install omits development dependencies. In the production image this made every module with a schema fail: the API exited at startup and Doctor reported NOT READY. - Move drizzle-orm to module-sdk's dependencies, keeping the peer. - Move react to @ekklesia/ui's dependencies, keeping the peer. The new guard flags it the same way; it is bundled at build time today, so it was not a live failure. Both lockfile entries keep the identical resolution, so nothing new is installed and the shell bundles still contain one React. - Add the boundary rule runtime-imports-are-production-dependencies: code a package ships (src/, bin/, excluding tests and .d.ts) must declare every runtime import in dependencies or optionalDependencies. Type-only imports are exempt; .tsx/.jsx implies react (automatic JSX runtime). Fixtures cover peer-only, dev-only, JSX, undeclared, and the accepted cases. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
06e55cebd5 |
docs: propose ADR 0044 reference deployment; fix manifest version
Add ADR 0044 (Status: Proposed), the single self-hosted reference deployment composed from pinned sources, and list it in the ADR index. Correct CLAUDE.md, which described manifest v6; the contracts accept only manifestVersion 7 (MODULE_MANIFEST_VERSION in packages/contracts/src/module-manifest.ts). No code changes. Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
|
|
20d5128bad |
docs: record the five Phase 8 prerequisites, and report contributions
Bring the living documentation in line with what the five passes actually shipped, and correct the claims they superseded rather than leaving them to mislead: the module UI context is six members, not five; module styling is compiled CSS, not inline objects only; the strict manifest is version 7. Also close a silent failure the new contribution mechanism introduces. A contribution whose target capability nobody provides — or whose provider moved outside its declared range — is not published, and the manifest is valid and the artifact exists, so an operator sees a screen that renders nothing with no error anywhere. `pnpm modules:validate` now lists each declared contribution with its capability and range and says whether a compatible provider is installed, and Doctor's discovery check reports the same per module. Both derive from the publication registry the API serves from, so neither can disagree with what a running platform would actually publish. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
ca3f37d5f9 |
feat: let a display survive its network — as code, not as authorization
A display is the one surface expected to outlive its network: a television on a wall in a building whose uplink is a consumer router, with nobody standing in front of it. When that uplink drops and the screen reboots, the browser shows its own error page — not the display shell, not an Ekklesia message, nothing that recovers on its own, because there is no Ekklesia code left running to recover. Cache the shell and the declared code, in a service worker scoped to `/display/`: the page, the host script, the module's display artifact, and every contributed renderer that display actually mounted. All of them are code whose bytes already confer no authority, and all of them are addressed by a URL carrying an exact version. Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a module or platform API response, not the realtime stream, not a pairing exchange, and not any cross-origin request — which is what a signed object-delivery URL is. It is an allowlist rather than a deny list, so the cost of forgetting a new endpoint is that code goes uncached rather than that a permission decision gets stored. The consequence is stated rather than papered over: a display cannot render module content while offline. Mounting needs a descriptor, a descriptor is authorization-bearing, and it is never cached — so a revoked device stays revoked and cached code can never manufacture current authorization. What the cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline state and keeps retrying, that reconnecting mounts with nothing left to download, and that a hiccup fetching 600 KB of artifact after the tiny descriptor already resolved falls back to identical bytes instead of failing the display. Network-first everywhere, so a screen picks up new code the moment the server returns and can never be trapped on an obsolete build — the failure mode a cache-first worker produces on a screen nobody visits. Only a complete same-origin 200 is stored. Cache names carry a policy version and superseded generations are deleted; artifacts are pruned to the current version per module, keeping every artifact belonging to that version because a display artifact and its contributed renderers share one. The worker never caches itself. The page gets a small signal built from two sources, because either alone lies: `navigator.onLine` is optimistic behind a captive portal, and only the worker knows it actually served from cache. A display shows a quiet corner indicator rather than blanking a lobby wall, and resolves its descriptor again by itself when the network returns — nobody presses Retry on a lobby TV. Every caching decision lives in a module that touches no global, so the behaviour is exercised directly rather than through a worker a test runner cannot host. Registration is an enhancement: no service workers, plain HTTP, or a refusing policy all leave a display that works exactly as before. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
e36485bf99 |
feat: let a module route stream an image-sized upload, explicitly and bounded
ADR 0017 capped a module route at 1 MiB and said large media belonged to a future storage design. That design arrived — ADR 0030 gave modules a bucket, ADR 0031 took Ekklesia out of the read path — and it did not solve the write path: signed write URLs were deliberately declined, so the only way bytes reach a bucket is a module's own route, and a photograph is not 1 MiB. Extend the route contract rather than adding a second HTTP system. A route declares `bodyMode`: `buffered` (the default, and what every existing route already meant) keeps the 1 MiB ceiling and materializes the body before the handler runs; `streamed` raises the ceiling to 32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather than inferred from the size, because inferring it would mean an author who raised a limit silently changed how their own handler receives its body and when a 413 can reach their own code. 32 MiB holds one 25 MiB image plus bounded overhead and is also the platform's absolute ceiling: no route in any mode may declare more, and no unbounded option exists in the contract. The unit is one file per request — twenty images are twenty individually bounded requests, and nothing here can express a 500 MiB aggregate whose failure would waste everything already sent. Replace the buffering dependency with the platform's own limiter. A declared Content-Length over the limit is refused synchronously, before authorization and before a byte is read. Buffered mode now counts what actually arrives rather than trusting the header, which closes a gap the previous implementation had. Streamed mode wraps the body in a counting transform; on overflow the stream errors, the source is cancelled, and the response is replaced with the same sanitized 413 even if the handler swallowed the error — a limit a handler can swallow is not a limit. An unread body is cancelled so an abandoned upload releases its socket, and an abort stays an ordinary failed request rather than becoming a 413. Ordering is unchanged and now strictly better: in streamed mode no byte is read until the handler reads it, and the handler runs only after authorization succeeds, so an unauthorized caller cannot push an upload through at all. The live suite uploads a real 25 MiB body over a real socket end to end. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
c444ff570b |
feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
07835b7b56 |
feat: give module UI real stylesheets, and every artifact its own root
ADR 0023 refused stylesheet imports for a good reason: esbuild would emit a
`.css` sidecar beside the artifact, nothing would declare it, no host would
fetch it, and it would work in a local test and silently do nothing in a real
host. Inline style objects were the whole story. They cannot express
`:hover`, `:focus-visible`, `:disabled`, `@media`, or `@keyframes` — so an
accessible focus treatment was not expressible, and the shared elements added
in the previous commit are built from real CSS for exactly that reason.
Compile the stylesheet into the artifact instead of beside it. A module
imports plain `.css`; the canonical builder resolves local `@import`s through
a nested bundle and turns each file into its compiled text, which the author
declares as `defineModuleUi({ styles })`. One declared file still contains
everything: nothing extra to publish, nothing to fetch, and no way for CSS to
become an asset nobody serves. The build additionally fails if a CSS output
ever appears beside an artifact, so a future loader change cannot bring the
sidecar back. Two rules are enforced with their own codes: a module stylesheet
may reference `--ekklesia-*` and never declare one, and every `@import` must
resolve locally or the artifact would fetch styles at run time. Plain `.css`
only; other dialects are a toolchain decision needing their own ADR.
Then make the isolation real. The generic loader mounts every browser artifact
— primary, display, and later a contributed renderer — inside an open Shadow
DOM root it creates, installs the artifact's stylesheet there, and hands the
artifact an element inside it. Shadow DOM is the one mechanism a browser
actually enforces; a class prefix or a "scope your selectors" instruction is a
convention one mistake defeats, and the failure mode is a module silently
restyling the Dashboard. It applies unconditionally, including to artifacts
with no stylesheet, because a conditional boundary is one a module can fall
out of by editing a source file. Both wrappers are `display: contents` through
base rules installed before the artifact's own, so layout is unchanged and a
full-viewport display can still override `:host`. Unmount removes the whole
root in a `finally`, so a cleanup that throws cannot leave styles behind, and
remounting cannot accumulate a second copy.
This is a correctness boundary for styles and is not described as a sandbox:
the root is `open` precisely because module code is trusted, same-realm code
that could reach `document` anyway.
An artifact's DOM is no longer a direct child of the host's element, so the
mount ABI is versioned explicitly. `defineModuleUi` stamps
`MODULE_UI_ABI_VERSION` so it can never drift from the SDK the artifact
compiled against, and a version-1 artifact fails with its own code — "rebuild
your module", not a typo hunt. No dual acceptance, matching every manifest
cutover here. Migration is one `pnpm build`.
`pnpm boundaries` allows `.css` only under `src/ui/` and `src/display/`: a
stylesheet anywhere else in a module is the undeclared sidecar again, just
relocated.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
e03d38fcab |
feat: give every Ekklesia screen one set of design tokens and elements
Modules have owned their own UI since ADR 0021, but the platform has never said what that UI should look like. Every screen here — the Dashboard shell, the display host, the module template — invented its own hex colours, font stack, spacing, and radii, and they already disagreed. A module author had no vocabulary for "the ordinary panel background", so the only available answer was to invent one, and two modules would invent two. Add `@ekklesia/design-tokens`: one validated, enumerable registry of semantic tokens. A token declares a name, label, description, and default value; its category and `--ekklesia-*` identifier are derived from the name, so it can never claim one category while being named for another. Construction rejects duplicates, malformed names, unknown categories, empty or overlong text, and any value that is not a bounded literal — no declaration terminator, rule block, comment, at-rule, `url(`, `!important`, escape, control character, or markup. A registry that constructs can therefore be emitted as CSS without escaping, because nothing needing escaping can be in it. That validation is also what makes a future theme editor implementable rather than aspirational. Add `@ekklesia/ui`: button, icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and list primitives, badge, toast, and empty/error/loading states — every value a token reference, with real `:focus-visible` treatment and status that never depends on colour alone. Deliberately small: an element earns its place when two consumers need it. The stylesheet is a string rather than a `.css` file so the identical bytes install into a document or an isolated artifact root, and so hover, disabled, and focus-visible are real CSS rather than JavaScript approximations of them. `pnpm boundaries` opens exactly two shared roles to modules, by name — every other role stays rejected — and pins both design packages to the bottom of the presentation stack. Both are browser-safe by construction, proven by a real compile of module-shaped source in which `process.env` must fail to typecheck. The shell installs the defaults at its document root for the Dashboard and the display; custom properties inherit into module artifacts, so a module reads `var(--ekklesia-…)` with nothing to import at run time and nothing for a host to forget to pass. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
ea28f8aa2e |
feat: verify email addresses and recover forgotten passwords
Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
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]> |
||
|
|
fa3021e49b |
feat: add a host-provided outbound email transport
Ekklesia could authenticate people and pair devices but could not send a single message. Better Auth has been configured with emailAndPassword since ADR 0011 and neither of its mail hooks — sendResetPassword and emailVerification.sendVerificationEmail — was ever implemented, so email verification, password recovery, and invitations were all blocked on the same missing thing. That missing thing is a transport, not a Google Workspace integration. Email is now a host-provided infrastructure service with a provider seam, shaped exactly like object storage: feature code says "send this transactional message" and the provider owns the host, port, TLS, credentials, connection behaviour, and every transport error. No provider's hostname appears anywhere in Core, and a test asserts it. Google Workspace SMTP Relay is the expected first deployment and a compatibility target — the same variables address Microsoft 365, a transactional provider, or a local Postfix. The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or HTML, an optional reply-to, and nothing else. There is no `from` field at all — the sender is host configuration, so no feature and no future module can choose who mail appears to come from, which is how a platform otherwise acquires an impersonation primitive. Control characters are refused rather than stripped in addresses, display names, subjects, and reply-to values; writing that rule down was not enough, because the first implementation trimmed an address before checking it and silently accepted a trailing carriage return until a test caught it. Security modes are named rather than inferred from a boolean and a port, which is genuinely ambiguous: `secure: false` on 587 means "connect in the clear and hope STARTTLS is offered". `starttls` sets requireTLS so a stripped upgrade fails the send instead of continuing in plaintext, production refuses `plaintext` outright, and certificate verification has no off switch. Credentials are optional as a pair, because a relay authorized by source IP has neither and requiring them would make the intended production arrangement unconfigurable; half a pair fails loudly. Connection, greeting, and socket timeouts are all explicit — the object-storage pass already showed what a network client on library defaults does to a request. Nothing retries: a failure after the message data has gone out is ambiguous, and retrying it turns one password-reset request into several emails, so this is a single bounded attempt and deliberately not an exactly-once promise. Email stays optional. An installation with no SMTP host is a complete deployment, nothing sends at startup, and constructing the transport opens no socket. There is no POST /api/v1/send-email and no route that touches the provider — an authenticated send endpoint is an open relay with extra steps. Modules get nothing either; services are added one at a time when something real needs one, and nothing does yet. Proven against a real in-process SMTP server rather than a mock: delivery with the host's sender on the wire, authentication and its sanitized refusal, a rejected mailbox distinguished from an outage, a refused connection, a silent server hitting its deadline, and a starttls transport refusing to continue when the upgrade is not offered. No test sends real email and no live credential exists in this repository or in CI. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
80189c8ae2 |
feat: give display devices their own paired identity
ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
c8fa13dd6a |
feat: create the initial platform owner from an operator command
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.
pnpm identity:create-initial-owner -- --email [email protected]
creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.
The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.
The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.
An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.
Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.
The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.
Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
a8c7c50b4d | feat: add browser auth shell | ||
|
|
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]> |
||
|
|
288c088377 |
docs: close Phase 5D security hardening
The documentation still described Phase 5D as in progress, Phase 6 as blocked, and a disposable-runner isolation proof as a prerequisite for opening the real pull request. All five passes and the independent whole-repository re-audit have landed, so those statements no longer match the accepted operating model. The README's current status now records Phase 5D as complete under the current trusted-contributor, not-yet-deployed threat model, with Phase 6 unblocked, and gains a pass 5 closure paragraph covering the re-audit, the repository-local supply-chain and dependency fixes, the accepted trusted-code-only CI boundary, and what remains required before untrusted CI or a public launch. The pass 1-4 paragraphs keep their technical content unchanged; only their tense changes, so each reads as what was outstanding at the close of that pass rather than as a current claim. `platform-architecture.md` no longer stops at pass 1 or says passes 2-5 are unfinished; it summarizes all five passes and defers the remaining operational limitations to the security document. `security.md` states the trusted-code-only operating rule explicitly and replaces "CI blockers before the real pull request is opened" with "Requirements before executing untrusted contributions", framing the five hardening items as prerequisites for relaxing that rule rather than as Phase 6 blockers, and naming an isolated hosted-runner service as an accepted alternative. The Gitea 1.24.2 warnings, the scope of `persist-credentials: false`, and the unverified runner topology and job-token authority are preserved verbatim; no external Gitea setting is claimed. The launch checklist is marked as gating a public launch, not development. Both Doctor examples are corrected to the real three-migration output, adding `core:0002_authorization_integrity · 634294731180`. The stale sentence calling Core authorization the next architectural phase is refreshed. ADRs 0017-0020 are untouched: their statements that later passes were outstanding are historical records made when each was accepted. Documentation only — no production code, test, workflow, dependency, migration, or schema change. 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]> |
||
|
|
6912680113 |
fix: prevent migration timeout overrides
pg-connection-string retains arbitrary query keys, so the driver's own configuration key connectionTimeoutMillis reaches ConnectionParameters straight from a URL — and it was not on the rejected list, while spellings that do nothing were. Rejecting it closes that gap. The precedence claims that guard was built on were partly wrong, so every source was measured against the installed [email protected].0, a real blackhole listener, and a real PostgreSQL rather than inferred from the merge: statement_timeout=0 / =900000 server reported 0 and 15min — wins outright query_timeout=0 overrides the field, but the parsed value is the string "0", which is truthy, so every query gets a 0 ms read timer and fails nondeterministically — it breaks the client rather than loosening the bound connectionTimeoutMillis=0/900000 pollutes connectionParameters.connect_timeout (10 -> 0 or 900 s), but that field only feeds getLibpqConnectionString(), which the pure-JS client never calls; the effective timer was unchanged, with an explicit 500 ms still expiring at ~500 ms either way connect_timeout=0 no effect: recomputed from the explicit field options=-c statement_timeout=… no effect, including a backslash-escaped spelling — the named startup parameter wins PGOPTIONS / PGCONNECT_TIMEOUT no effect unrelated parameters no effect, deliberately untouched Only statement_timeout is a genuine downgrade. The rest are rejected as unsupported configuration and defense in depth: no legitimate DATABASE_URL carries a driver option, the polluted connect_timeout would take effect on the native binding, and query_timeout produces intermittent failures an operator could not diagnose. That distinction is now stated where it was previously asserted wrongly — the earlier comment and ADR text claimed connect_timeout overrode the explicit field, which measurement disproved. Rejection still happens at the one shared construction boundary, before any pg.Client exists, with the existing fixed POSTGRES_MIGRATION_CONFIGURATION_INVALID and no URL, host, port, credential, parameter, or value in the message. The 10 s defaults, narrower test overrides, Pass 2 TLS policy and its URL-conflict checks, and every unrelated parameter are unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
2983789779 |
fix: harden migration command execution
Two problems in the migration commands: no timeouts, and SQL split on literal bytes. `migrations:plan` and `migrations:apply` constructed their connection with neither a connection deadline nor an operation deadline — a real server reported `statement_timeout = 0` on the migration connection. Both now default to 10 seconds for the connection deadline, the client query timeout, and PostgreSQL's own statement_timeout, applied at the one shared construction boundary so a future call site cannot silently omit them. History reads, advisory-lock queries, transaction control, migration SQL, and history writes are all covered. The existing lock-wait deadline, the Pass 2 TLS policy, and Doctor's and the bootstrap CLI's own timeouts are untouched, and no environment variable or flag was added. Explicit client fields do not win under the pinned driver. [email protected].0 builds parameters as `Object.assign({}, config, parse(config.connectionString))`, so the URL overrides passed options — verified directly, where an explicit statement_timeout of 10000 alongside `?statement_timeout=0` produced a server-reported 0, while a clean URL produced 10s. A DATABASE_URL carrying connect_timeout, query_timeout, statement_timeout, or an `options` value setting statement_timeout is therefore refused with the existing fixed configuration error, before any client is constructed and without echoing the URL, parameter, or value. Unrelated parameters are left alone. The runner split each migration on the literal `--> statement-breakpoint` bytes. Those bytes inside a string literal, a dollar-quoted function body, a quoted identifier, or a comment tore the migration into fragments that were no longer valid SQL — each case verified to produce two fragments where one was required. Each migration's exact SQL is now passed to the client once, with no values and no prepared-statement name, inside the existing transaction, and its history row inserted only after that batch succeeds. One transaction still spans all pending migrations; advisory locking, checksum validation, approval preflight, ordering, rollback, cleanup, and sanitized error identity are unchanged. The port method is renamed executeStatement to executeMigrationSql so the contract says what it receives. PostgreSQL parses the batch because it owns the only correct parser. A homegrown splitter would have to track single quotes, dollar quoting with arbitrary tags, quoted identifiers, line comments, nested block comments, and escape syntax to answer a question the server already answers, and a parser dependency would add supply-chain surface for the same reason. Drizzle's marker lines are ordinary `--` comments when the batch is sent whole. No custom SQL parser exists here. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
5c589d8fec |
fix: enforce explicit grant invariants
Explicit-grant management could produce states an operator could not reason about, and pushed request-shaped problems into the database. Two identical createGrant calls produced two grants; five concurrent identical calls produced five. Revoking one returned success while the equivalent kept authorizing the same user — an operator could believe they had removed access that was still live. Two issuances are now the same authorization source when user_id, permission_id, scope, source_kind, and source_ref are all equal, with NULL equal only to NULL; expiry, creation metadata, actor, and grant id do not distinguish them. Creating one while an equivalent is currently effective fails with AUTHORIZATION_GRANT_ALREADY_EXISTS (HTTP 409, no-store) naming nothing about the grant that already exists, and writing no grant and no audit row. A revoked or expired equivalent does not block a fresh issuance. Conflict rather than idempotent return: the caller asked to record a new authorization with their own actor and expiry, and answering "done" while recording neither would hide that nothing happened. Enforcement is database-backed, since nothing constrains the number of API processes: every create and revoke touching one equivalence tuple takes a transaction-scoped advisory lock keyed by a hash of that tuple, encoded as a JSON array so NULL stays distinct from the empty string and a crafted scope cannot forge a separator. A collision can only make unrelated tuples serialize, never hide a conflict, because the decision is always an exact SQL predicate. Its single-key lock space is verified disjoint from the two-key space the migration runner and bootstrap use. A time-dependent partial unique index was rejected: now() is not immutable and cannot be indexed. One revocation now clears every unrevoked equivalent — including expired-but- unrevoked legacy rows, so the history is left in one state — with one audit row per grant actually revoked, each carrying its own id, in one transaction. Differently scoped or sourced grants are untouched. Historical duplicates are not silently revoked by migration; the store prevents new effective equivalents and an operator's explicit revocation resolves the class. The request boundary no longer leaks into PostgreSQL. GrantId accepts exactly the canonical lowercase hyphenated UUID the database emits, with no trimming and no case folding, so a malformed path parameter is a 400 decided after authorization but before any store call instead of reaching the UUID comparison and returning an indistinguishable 503; a canonical unknown id still returns 404. expiresAt accepts exactly YYYY-MM-DDTHH:mm:ss.sssZ, byte-for-byte equal to the parsed date's own toISOString, and must be strictly future — bare new Date silently rewrote 2026-02-31 to March 3rd and accepted locale, date-only, offset, and millisecond-free forms, and a past expiry was stored as a grant dead on arrival. The store validates the same thing before any SQL so trusted internal callers get the guarantee too. The generic HTTP surface now rejects a scope field in every form, including null. No route middleware supplies a scope to the evaluator, so a scope accepted here produced a grant that persisted, listed, and authorized nothing. Core's scope semantics are unchanged for internal callers; no resolver was added. Listing was unbounded and returned a user's entire history. It is now keyset pagination over created_at DESC, id DESC with a strict boundary on both values, so rows sharing a timestamp are neither skipped nor repeated, at most limit + 1 rows read, explicit-source filtering server-side on every page, and an opaque unsigned position cursor that is strictly decoded and reveals nothing on rejection. Core's list method requires a validated finite bound with no unbounded fallback anywhere. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
6b6b77c105 |
fix: enforce production transport policy
Every PostgreSQL connection this repository opened was plaintext, in every environment: no connection site set `ssl` at all, so credentials, session tokens, and row data crossed the network in the clear and nothing in configuration, startup, or `pnpm doctor` said so. The public identity URL boundary was also looser than it appeared. `core/src/database/transport.ts` is now the one typed policy. It has exactly two modes: `verify-full` (TLS required, certificate chain verified, and server identity verified against the configured host — the production default) and `disable` (the development/test default, refused outright in production). There is deliberately no encryption-only `require` mode, no chain-only `verify-ca`, and no `rejectUnauthorized: false` escape hatch anywhere in the configuration surface; every loose or aliased spelling is rejected in both environments, so a typo never quietly becomes a weaker mode. Production has no plaintext exception at all, not even for a loopback database — a connection string cannot prove the database is process-local. Verification is certificate *and* hostname. The policy supplies an explicit `checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the pinned driver sets TLS `servername` only for non-IP hosts and Node performs no identity check when it is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA ever signed when connecting by IP. Both halves are proven against a real TLS-enabled PostgreSQL, including that the same connection without the pinned callback succeeds. Every CA, certificate, and key is generated at test runtime and deleted afterwards. A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver merges parsed connection-string values over explicitly passed options, so a `?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object; that now fails with a stable code, reported by both `pnpm doctor` and the migration CLIs rather than as "failed unexpectedly". One shared environment reader keeps `process.env` out of Core's reusable constructors, and every executable that opens a connection resolves the policy identically: the API (validated before any pool, Better Auth instance, module runtime, or listener exists), both migration commands, the bootstrap CLI, and Doctor's own connection. Doctor reports it as a blocking failure, never a warning, naming only the effective mode. Separately, the identity base URL and every trusted origin must now be exactly one canonical origin — the WHATWG origin serialization must equal the supplied string — so a path, trailing slash, credentials, explicit default port, or non-canonical case is rejected rather than normalized into something that no longer matches what a browser sends. Production's loopback rejection now classifies the address instead of comparing against three literals, covering `.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped forms. This is a configuration-shape guarantee only: it does not resolve DNS or prove reachability, and TLS protects the connection, not the exposure. Co-Authored-By: Claude Opus 5 <[email protected]> |