Commit Graph
20 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 a12cbe9600 feat(api): serve the built Dashboard from the API origin
CI / verify (pull_request) Successful in 46s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m44s
Serve surfaces/shell's built Dashboard from apps/api on the same origin
as /api (ADR 0044), exactly as the display host is served: a closed set
of files read once at startup, never a static directory, never a path
built from request input.

- The shell exports @ekklesia/shell/dashboard-assets (the exact built
  index.html and script URLs), emits the script at
  assets/ekklesia-dashboard/app.js, and index.html names that absolute
  path so dev and production serve the same URLs.
- GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen
  snapshot: no-cache with a strong ETag and 304 on match. Source maps
  are never served; every other path is the ordinary sanitized 404.
- The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy:
  frame-ancestors 'none' (that directive only), and Referrer-Policy:
  no-referrer, on 200 and 304 alike.
- A missing or empty shell build fails startup with a fixed
  DashboardHostAssetError before any module code is imported.
- The If-None-Match comparison moves to conditional-request.ts, shared
  with the display host routes.
- The emailed-link token stripping moves from browser/main.tsx into
  consumeAuthLanding (location and history injected) so it is tested;
  behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 17:54:55 -05:00
ekklesiaandClaude Opus 5.5 aaec9789c4 feat(api): add unauthenticated health endpoints
CI / verify (pull_request) Successful in 45s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m42s
Add GET /api/v1/health/live and GET /api/v1/health/ready per ADR 0044.

- live returns a fixed 200 {"status":"live"} whenever the process serves.
- ready returns 200 {"status":"ready"} only when module startup has
  completed (and shutdown has not begun) and a database ping succeeds
  within 2s; otherwise a fixed 503 {"code":"NOT_READY"}.
- The ping runs on the shared pool as one simple-protocol message with
  a transaction-local statement_timeout, so PostgreSQL cancels a stalled
  statement; the pool discards the connection on failure.
- At most one ping is ever unsettled: probes share its outcome and, once
  its deadline passes, answer 503 immediately until it settles.
- Both routes set Cache-Control: no-store, accept GET only (405 with
  Allow: GET otherwise, HEAD included), never resolve a principal, and
  are mounted in app.ts beside, never inside, /api/v1/modules/.
- createApiApp gains a required `health` option; startApi supplies it
  and clears module readiness when shutdown begins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 16:47:01 -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
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 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 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
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 0e308eb9ad feat: define module database access
Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
2026-07-25 20:57:32 -05:00
ekklesiaandClaude Sonnet 5 300f98c29d fix: normalize final CORS Vary headers
CI / verify (pull_request) Successful in 26s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 27s
Found during a targeted post-audit review: sanitizeModuleResponse's Vary fix
(bf76f4e) canonicalized the module response's own Vary before Hono's own
cors() middleware (apps/api/src/app.ts) ran, but cors() unconditionally
appends its own "Vary: Origin" afterward, outside module-http-routes.ts's
reach. The wire-level response app.fetch actually returns could therefore
still read "*, Origin" for a module handler that sets Vary: *, or carry a
case-different duplicate Origin token — reproduced first via a new app-level
regression test hitting the final response, not sanitizeModuleResponse in
isolation.

Fixed by wrapping the shared corsMiddleware in withCanonicalVary
(apps/api/src/app.ts), which re-canonicalizes whatever Vary cors() leaves
behind only after it has fully finished, handling both the normal
next()-driven path (via c.header(), since reassigning c.res triggers Hono's
own header-preserving merge and would silently undo the fix) and the
OPTIONS-preflight path (cors() returns a Response directly without touching
c.res). Neither path reads or clones the response body.

Refactored module-http-routes.ts's Vary logic into one shared parsing/dedup
primitive with two policy-specific callers: mergedVaryHeader (module
responses; always forces canonical Origin/Cookie, unchanged behavior) and
the new canonicalizeVaryHeader (the app-level CORS boundary; only
deduplicates and collapses "*", never injects a token that was not already
present — an OPTIONS preflight has no Cookie/personalization concept to
force). This also incidentally fixes the same pre-existing Origin
duplication on /api/v1/principal (present since Phase 4B, flagged as a
remaining concern in the prior pass), since it wraps the one shared
middleware every /api/v1/* and /api/auth/* response already passes through.

ADR 0015 required no changes: its Vary/CORS ownership statements are
architectural, not tied to a specific function, and remain accurate.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-25 10:39:06 -05:00
ekklesiaandClaude Sonnet 5 dfaf324a65 feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement,
and module-runtime lifecycle integration.

apps/api now discovers the installed module inventory exactly once
(prepareStartupModuleInventory), composes the platform permission
registry from it, starts the module runtime from that same discovered
list (startDiscoveredModuleRuntime, never rediscovering), and mounts
StartedModuleRuntime's published routes under
/api/v1/modules/<module-id> via a new apps/api-owned Hono adapter
(module-http-routes.ts). Every module route is protected by the
existing requirePermission middleware against the composed registry,
state-changing methods additionally require a trusted Origin, OPTIONS
remains entirely platform-owned, and a handler that throws, rejects,
or returns a non-Response becomes a fixed, sanitized
500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped
and every module response is forced to Cache-Control: no-store with
Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime
between draining the HTTP server and closing the shared pool, and
every startup failure path stops the runtime and/or closes the pool
before propagating, without masking the original failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 19:16:20 -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 1363608552 feat: add authorization bootstrap and HTTP enforcement
Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
2026-07-23 11:16:32 -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