Commit Graph
8 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5 44ba259f58 feat: establish the module product contract
CI / verify (pull_request) Successful in 2m31s
CI / infrastructure-smoke (pull_request) Successful in 15s
CI / migration-smoke (pull_request) Successful in 49s
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]>
2026-08-21 20:20:41 -05:00
ekklesiaandClaude Opus 5 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]>
2026-07-26 17:03:52 -05:00
ekklesiaandClaude Opus 5 1161f5a076 fix: fail closed on production signup
Production configuration accepted publicSignupEnabled: true and started a
working Identity/API runtime, even though Ekklesia has no verified email,
invitation requirement, CAPTCHA, or durable shared/edge limiter — Doctor
merely reported "Public signup is explicitly ENABLED" as a pass.

validatePublicSignupEnabled now takes the same IdentityEnvironment the rest
of this config validates against and throws the stable
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION whenever the parsed value is true in
production. Parsing stays separate from the policy, so a malformed value
still reports PUBLIC_SIGNUP_ENABLED_INVALID everywhere. Because the
executable entry point validates configuration before startApi, such a
deployment fails before any pool, Better Auth instance, module runtime, or
listener exists, and Doctor reports NOT READY via its existing
IdentityConfigError path. Development and test composition still enable it
explicitly through the identical typed path.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 15:58:08 -05:00
ekklesiaandClaude Sonnet 5 cafd513019 fix: secure authentication request identity
Replace Better Auth's default trust of a caller-controlled X-Forwarded-For
with a platform-owned client-address resolution boundary: direct mode (the
safe default) trusts only the real socket peer, and an explicit trusted-proxy
mode requires a header name, a concrete trusted-proxy list, and only honors a
forwarded chain from an actual trusted peer. This closes a rate-limit bypass
where rotating a spoofed header let a caller choose a fresh bucket on every
request. Public sign-up is also disabled by default and requires an explicit
typed opt-in, using Better Auth's own disableSignUp gate so no email-existence
leak or second signup path is introduced.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 12:20:51 -05:00
ekklesia edd7148aa8 feat: integrate module authorization
Wires composePlatformPermissionRegistry into apps/api's production
startup: startApi() discovers every installed module, composes the one
immutable platform permission registry (Core's own declarations plus
every module's), and only then constructs the shared PostgreSQL pool,
Better Auth, and the HTTP server. A discovery or composition failure —
an invalid manifest, a reserved core.* namespace claim, or a duplicate
declaration — propagates before anything exists to close; there is no
Core-only registry fallback. apps/api/src/platform-permissions.ts
resolves the repository root from its own file location, never
process.cwd(), so package-scoped test runs discover the right modules/
directory.

createApiApp/createGrantsRoutes now require the composed registry as
an injected authorization option, replacing the previous hard-coded
CORE_PERMISSION_REGISTRY; requirePermission continues to work
identically for Core-owned and module-owned permissions, since it has
no concept of the distinction.

Extends pnpm doctor with a modules.permissions check (reusing the
already-discovered module list, distinguishing reserved-namespace from
generic-duplicate failures, never re-discovering modules) and pnpm
modules:validate to compose the same registry before a module is
considered installable.

Adds focused coverage: startup-ordering and failure-isolation tests in
apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement
tests in apps/api/tests/authorization-middleware.test.ts, Doctor
composition tests in core/tests/doctor.test.ts, and a live PostgreSQL
end-to-end scenario proving a real installed module's permission is
discoverable, grantable, and deniable once the module is absent again
after a restart, without ever executing its server entrypoint.
2026-07-24 14:24:50 -05:00
ekklesia d9dd409aa3 feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract
(packages/contracts/src/authorization.ts) — id/baseline/description,
strict, unknown-property-rejecting — consumed by both Core's
authorization registry and module manifests, replacing Core's
previously separate interface.

Bumps the canonical module manifest to manifestVersion 3: permissions
become complete declarations instead of bare identifier strings, with
manifest-owned namespace and duplicate-id enforcement. Version 2 and
bare string-array permissions are rejected outright; there is no
compatibility adapter.

Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts),
a pure, synchronous composer that builds one platform permission
registry from Core's own declarations plus every already-discovered
module's, in deterministic order, reusing createPermissionRegistry
and rejecting any module-contributed permission in the reserved
core.* namespace. core/src/authorization remains independent of
core/src/modules; only the module integration layer depends on
authorization.

Updates the module template, module-development docs, authorization
docs, and existing manifest fixtures for manifestVersion 3. Does not
wire the combined registry into apps/api — that is pass 2.
2026-07-24 12:25:00 -05:00
ekklesiaandClaude Sonnet 5 679a5d225c feat: establish core identity foundation
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 22s
Adds Core-owned identity contracts, a Core-owned Better Auth database schema
(ekklesia_core_user/session/account/verification), a first-class Core-owned
migration source that coexists with module-owned migrations under the exact
existing migration system, and an explicit Better Auth construction boundary.
No authentication endpoint or sign-in UI is exposed yet (ADR 0010).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-21 16:39:15 -05:00
ekklesia 02ee3ccd80 feat: add Ekklesia doctor command
CI / verify (pull_request) Successful in 20s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s
2026-07-21 10:42:21 -05:00