feat: add module HTTP transport #12

Merged
Zayin_Admin merged 5 commits from phase-5/module-http-transport into main 2026-07-25 15:44:40 +00:00
Owner

Summary

  • Adds the framework-neutral module HTTP route contract.
  • Publishes immutable module routes atomically during runtime startup.
  • Discovers modules once and uses the same inventory for permission composition and executable runtime startup.
  • Mounts module routes beneath /api/v1/modules/<module-id>.
  • Enforces composed-registry permissions on every route, including public routes.
  • Requires trusted origins for POST, PUT, PATCH, and DELETE.
  • Keeps OPTIONS, CORS, cache policy, and handler-error sanitization platform-owned.
  • Preserves streaming responses and canonicalizes final Vary headers after Hono CORS processing.
  • Integrates module runtime startup, rollback, shutdown, and PostgreSQL cleanup.
  • Records the resulting architecture in ADR 0015.

Verification

  • Root: 41
  • Contracts: 110
  • Module SDK: 5
  • Module template: 4
  • Core: 330
  • API: 143
  • Total unit tests: 633
  • PostgreSQL integration: 99/99
  • Doctor: READY WITH WARNINGS

Scope

  • No database schema or migration changes.
  • No manifest or manifest-version changes.
  • No dependency or lockfile changes.
  • No Hono coupling below apps/api.
  • Module installation, removal, or route changes require an API restart.
## Summary - Adds the framework-neutral module HTTP route contract. - Publishes immutable module routes atomically during runtime startup. - Discovers modules once and uses the same inventory for permission composition and executable runtime startup. - Mounts module routes beneath `/api/v1/modules/<module-id>`. - Enforces composed-registry permissions on every route, including public routes. - Requires trusted origins for POST, PUT, PATCH, and DELETE. - Keeps OPTIONS, CORS, cache policy, and handler-error sanitization platform-owned. - Preserves streaming responses and canonicalizes final `Vary` headers after Hono CORS processing. - Integrates module runtime startup, rollback, shutdown, and PostgreSQL cleanup. - Records the resulting architecture in ADR 0015. ## Verification - Root: 41 - Contracts: 110 - Module SDK: 5 - Module template: 4 - Core: 330 - API: 143 - Total unit tests: 633 - PostgreSQL integration: 99/99 - Doctor: READY WITH WARNINGS ## Scope - No database schema or migration changes. - No manifest or manifest-version changes. - No dependency or lockfile changes. - No Hono coupling below `apps/api`. - Module installation, removal, or route changes require an API restart.
Zayin_Admin added 5 commits 2026-07-25 15:42:40 +00:00
Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime
registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical
path grammar, route definition/context/handler types, and validation)
and Core's module runtime staging/publication of a module's declared
routes alongside its capabilities, gated on the module's own manifest
permissions. Routes are collected into StartedModuleRuntime as an
immutable snapshot but are not yet mounted into apps/api; that is pass 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
Found during the Phase 5B pass-3 architectural audit: sanitizeModuleResponse's
Vary merge used a plain Set, so an existing "Vary: *" from a module handler
became "*, Origin, Cookie" (RFC 9110 §12.5.5 forbids combining "*" with any
other value), and a differently-cased existing token (e.g. "origin") produced
a redundant duplicate alongside the forced "Origin"/"Cookie". mergedVaryHeader
now de-duplicates case-insensitively, always emits canonical Origin/Cookie
casing, and collapses to a bare "*" whenever one is present, per focused unit
tests. The full app's own pre-existing cors() middleware (apps/api/src/app.ts,
unchanged here, present since Phase 4B on every /api/v1/* route including
/api/v1/principal) still unconditionally appends its own "Vary: Origin"
outside this module's control — noted in the new tests, not addressed by this
narrow fix.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
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
300f98c29d
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]>
Zayin_Admin merged commit 9c9ede0418 into main 2026-07-25 15:44:40 +00:00
Zayin_Admin deleted branch phase-5/module-http-transport 2026-07-25 15:44:40 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#12