Commit Graph
45 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 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]>
2026-10-03 14:47:36 -05:00
ekklesiaandClaude Opus 5.5 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]>
2026-10-03 13:29:11 -05:00
ekklesiaandClaude Opus 5.5 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]>
2026-10-03 13:29:11 -05:00
ekklesiaandClaude Opus 5.5 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]>
2026-10-03 10:03:33 -05:00
ekklesiaandClaude Opus 5.5 06e55cebd5 docs: propose ADR 0044 reference deployment; fix manifest version
CI / verify (pull_request) Successful in 47s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m43s
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]>
2026-10-01 16:08:44 -05:00
ekklesiaandClaude Opus 5 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]>
2026-08-27 16:08:34 -05:00
ekklesiaandClaude Opus 5 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]>
2026-08-27 15:57:44 -05:00
ekklesiaandClaude Opus 5 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]>
2026-08-27 07:47:10 -05:00
ekklesiaandClaude Opus 5 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]>
2026-08-27 07:21:14 -05:00
ekklesiaandClaude Opus 5 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]>
2026-08-27 06:59:44 -05:00
ekklesiaandClaude Opus 5 ea28f8aa2e feat: verify email addresses and recover forgotten passwords
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
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]>
2026-08-25 07:21:17 -05:00
ekklesiaandClaude Opus 5 fa3021e49b feat: add a host-provided outbound email transport
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s
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]>
2026-08-24 18:37:16 -05:00
ekklesiaandClaude Opus 5 80189c8ae2 feat: give display devices their own paired identity
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s
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]>
2026-08-23 23:41:03 -05:00
ekklesiaandClaude Opus 5 c8fa13dd6a feat: create the initial platform owner from an operator command
CI / verify (pull_request) Successful in 41s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m18s
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]>
2026-08-23 22:32:53 -05:00
ekklesia a8c7c50b4d feat: add browser auth shell
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s
2026-08-23 22:01:26 -05:00
ekklesia 2e0fe059c3 feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
2026-08-23 20:50:56 -05:00
ekklesia fc6c8045c4 feat: add native module realtime transport
CI / verify (pull_request) Successful in 37s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m7s
2026-08-23 18:47:18 -05:00
ekklesiaandClaude Opus 5 7ff063b87a feat: deliver module objects directly to browsers through opaque read URLs
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 56s
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]>
2026-08-23 17:12:07 -05:00
ekklesiaandClaude Opus 5 b9ca0d560d feat: add host-provided module object storage
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s
`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]>
2026-08-23 08:45:53 -05:00
ekklesiaandClaude Opus 5 baa689b2a7 feat: add the module configuration and secrets contract
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
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]>
2026-08-22 21:44:43 -05:00
ekklesiaandClaude Opus 5 7729c99e24 feat: add the minimal operational module lifecycle
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s
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]>
2026-08-22 14:45:34 -05:00
ekklesiaandClaude Opus 5 bd34c9c714 feat: add Dashboard navigation as presentation configuration
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 44s
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]>
2026-08-22 12:44:40 -05:00
ekklesiaandClaude Opus 5 616c1d4834 feat: mount discovered module UI in a minimal Ekklesia browser host
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 43s
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]>
2026-08-22 08:55:44 -05:00
ekklesiaandClaude Opus 5 a02deb9433 feat: publish validated module UI artifacts at a platform-owned URL
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 41s
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]>
2026-08-22 08:11:53 -05:00
ekklesiaandClaude Opus 5 44e676f0df feat: close the browser boundary and add the generic UI host loader
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 41s
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]>
2026-08-21 21:44:42 -05:00
ekklesiaandClaude Opus 5 1e5866504e feat: build module UI into its declared browser artifact
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 26s
CI / migration-smoke (pull_request) Successful in 50s
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]>
2026-08-21 21:24:18 -05:00
ekklesiaandClaude Opus 5 8d651c427d feat: define the module UI authoring model and host contract
CI / verify (pull_request) Successful in 30s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 45s
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]>
2026-08-21 20:52:47 -05:00
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 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]>
2026-07-26 23:13:52 -05:00
ekklesiaandClaude Opus 5 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]>
2026-07-26 21:56:32 -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 Sonnet 5 d3c6bd73d3 fix: enforce API request limits
Add a single platform-owned request-body policy (hono/body-limit) with
small, named limits for Better Auth and Core's grant-mutation endpoint, and
a new required maxBodyBytes field on the executable module route contract so
every module route makes a conscious no-body-or-bounded-body choice, capped
at a platform ceiling. Every oversized request gets one sanitized 413
contract and never reaches business logic, authorization, or a handler.
Also configure finite Node header/request/keep-alive timeouts, including the
non-obvious connectionsCheckingInterval needed for them to take effect
promptly, so a slow-header or slow-body connection cannot hold a socket
indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 12:29:24 -05:00
ekklesia c38b572778 docs: document module database access
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
2026-07-25 21:50:03 -05:00
ekklesiaandClaude Sonnet 5 1285c90618 docs: record module HTTP architecture
Phase 5B, pass 3: adds ADR 0015 recording the durable module HTTP
routing and production runtime lifecycle architecture pass 1 and pass
2 implemented, extending ADR 0006 and ADR 0014 without superseding
either. Adds narrow cross-reference notes to both, and to the ADR
index, pointing at ADR 0015 for the work they deferred, while
preserving their historical text and accepted status.

Corrects stale forward-looking "pass 2"/future-tense language left
over from pass 1 in core/src/modules/runtime.ts and
packages/contracts/src/module-http.ts (comment-only; production
behavior unchanged), and a shutdown-lifecycle description in
docs/development/testing.md that predated the module-runtime-stop
step pass 2 added between draining the HTTP server and closing the
pool.

Full audit against origin/main...HEAD found the architecture,
contract, startup ordering, routing, authorization/origin
enforcement, and PostgreSQL integration coverage from passes 1-2
sound; the one concrete defect found (Vary-header merge producing an
invalid "*, Origin, Cookie" combination and case-sensitive duplicate
tokens) was already fixed with focused regression tests in a separate
commit (bf76f4e), not amending either checkpoint commit.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-25 10:22:35 -05:00
ekklesia 9fa051be61 docs: record module authorization architecture
Adds ADR 0014 recording the durable architecture Phase 5A established
across both passes: the shared contracts-layer PermissionDeclaration,
manifest version 3 replacing version 2's bare permission strings, the
reserved core.* namespace and the structural guarantee that a
module-vs-Core collision always surfaces as
ModulePermissionCompositionError rather than the generic
PermissionRegistryError duplicate path, deterministic registry
composition, startup ordering, repository-root/package-version
resolution, registry injection into apps/api's enforcement and
explicit-grant HTTP API, the static-snapshot/historical-record
preservation guarantees, and Doctor/modules:validate integration.
Indexes it in the ADR README, cross-references it from ADR 0006 (only
its manifestVersion 2 / permission-string decision is superseded; the
rest of ADR 0006 remains accepted, and its historical text is left
untouched) and ADR 0013 (module permission registration, deferred
there, is what ADR 0014 implements), and links it from the relevant
development documentation.

Corrects two comments discovered during a direct source audit that
overstated where a module-vs-Core permission collision is actually
caught (core/src/modules/permissions.ts,
apps/api/src/platform-permissions.ts) — comment-only, no behavior
change, already covered by existing tests. Also corrects a
documentation claim in docs/development/authorization.md that
incorrectly implied removing a module blocks explicit revocation of
its historical grants; grants-routes.ts's revoke handler never
consults the permission registry, so revocation remains unaffected.
2026-07-24 17:49:42 -05:00
ekklesia 967c275027 docs: record core authorization architecture
Adds ADR 0013 documenting the durable Phase 4C decisions (permission
registry, evaluator baselines, grant/scope semantics, platform-owner
bootstrap, shared database pool, Origin enforcement, and what remains
deferred), cross-links it from the authorization/README docs, and
refreshes the pnpm doctor no-DATABASE_URL example output in README.md
and repository-workflow.md to include the authorization migration.
2026-07-23 11:26:47 -05:00
ekklesia d9b73fd81a fix: harden identity HTTP transport
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s
2026-07-21 23:50:41 -05:00
ekklesia 91d8f5946f feat: expose core identity transport
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 19s
CI / migration-smoke (pull_request) Successful in 26s
2026-07-21 19:15:14 -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 0b282865cb feat: add transactional module migration runner
CI / verify (pull_request) Successful in 19s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-21 08:52:41 -05:00
ekklesia 88c28d75c9 feat: add module migration contracts and validation
CI / verify (pull_request) Successful in 21s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-21 08:09:30 -05:00
ekklesia 80b56b72b1 feat: add module schema discovery and validation
CI / verify (pull_request) Successful in 24s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-13 21:35:21 -05:00
ekklesia 7b448d8706 feat: add module runtime lifecycle
CI / verify (push) Successful in 2m14s
CI / infrastructure-smoke (push) Failing after 1m36s
2026-07-12 22:49:51 -05:00
ekklesia 57552a21a4 feat: add module contracts and discovery
CI / verify (push) Has been cancelled
CI / infrastructure-smoke (push) Has been cancelled
2026-07-12 22:14:01 -05:00
ekklesia 192a6514cf chore: establish Ekklesia platform foundation
CI / verify (push) Has been cancelled
2026-07-12 20:28:19 -05:00