From c38b572778b234fd640efbbc0ba4e5d3d9266a5e Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 25 Jul 2026 21:50:03 -0500 Subject: [PATCH] docs: document module database access 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. --- README.md | 15 +- ...apabilities-and-compiled-module-runtime.md | 12 +- ...dule-http-routing-and-runtime-lifecycle.md | 14 ++ .../decisions/0016-module-database-access.md | 227 ++++++++++++++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 33 ++- docs/development/api.md | 102 +++++--- docs/development/authorization.md | 28 ++- docs/development/identity.md | 9 +- docs/development/testing.md | 17 ++ docs/module-development/README.md | 88 ++++++- templates/module/README.md | 16 +- templates/module/src/schema/tables.ts | 5 +- templates/module/src/server/index.ts | 21 +- templates/module/tests/manifest.test.ts | 87 +++++-- 15 files changed, 584 insertions(+), 91 deletions(-) create mode 100644 docs/architecture/decisions/0016-module-database-access.md diff --git a/README.md b/README.md index 2256c2b..9780bc2 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ surfaces. ## Current status Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), -Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The -repository includes +Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP +transport), and Phase 5C (module database access) have landed. The repository includes public module contracts, independently versioned capabilities, static module manifests, deterministic automatic discovery, compiled server loading, a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic @@ -80,6 +80,17 @@ See [API](docs/development/api.md#module-http-routes), [Module development](docs/module-development/README.md#http-routes), and [ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred). +Phase 5C gives a module's own lifecycle hooks and HTTP handlers typed access to their own +PostgreSQL tables. `context.services.database` — a restricted, typed `ModuleDatabase` view over +Drizzle's `select`/`selectDistinct`/`selectDistinctOn`/`insert`/`update`/`delete`/`execute`/ +`transaction`, never a raw `pg` client, pool, or connection string — is available to `start`, +`stop`, and every authorized route handler, and reuses the one PostgreSQL pool `apps/api` already +owns; registration remains declarative and database-free. Modules continue to own and import their +own Drizzle schemas and generated migrations, applied explicitly and never automatically at +startup. See [API](docs/development/api.md#module-database-access), +[Module development](docs/module-development/README.md#database-access), and +[ADR 0016](docs/architecture/decisions/0016-module-database-access.md). + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md b/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md index d176af2..4d9a5b1 100644 --- a/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md +++ b/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md @@ -28,14 +28,16 @@ registry, which conflicts with install-by-cloning discovery. > ordering — remains accepted and unchanged. This section is left as originally accepted, as the > historical record of the manifest's prior shape. -> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).** +> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md) and +> [ADR 0016](0016-module-database-access.md).** > "HTTP routing... remain[s] outside this slice" (Consequences, below) and "The injected contexts > contain only the module ID and a capability registrar or resolver" (this section, below) described > this ADR's own scope accurately at the time. ADR 0015 implements module HTTP routing: it extends -> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar, while -> `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) remains exactly as -> limited to module ID and capability access as originally decided here. Every other statement in -> this ADR remains accepted and unchanged. +> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar. ADR 0016 +> further extends `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) +> with a `services` field carrying the module database facade — `ModuleRegistrationContext` itself +> gains no such field and remains exactly as database-free as originally decided here. Every other +> statement in this ADR remains accepted and unchanged. `ekklesia.module.json` remains the one canonical static manifest and moves to `manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact diff --git a/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md b/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md index 4d99ddd..e0b0058 100644 --- a/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md +++ b/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md @@ -162,6 +162,16 @@ configured trusted origin — never a wildcard, and never anything module-influe ### A frozen, minimal handler context and sanitized failure boundary +> **Cross-reference — see [ADR 0016](0016-module-database-access.md).** +> "A frozen context of exactly `moduleId`... and an immutable, Hono-decoded parameter record" and "A +> handler never receives a Hono `Context`, a database connection... or any other middleware-internal +> state," below, described the handler context's complete shape accurately at the time. ADR 0016 +> adds a fourth field, `services`, carrying `ModuleRuntimeServices` (currently just a typed +> `ModuleDatabase` facade) — never a raw database connection, `pg.Pool`, `PoolClient`, connection +> string, or other infrastructure object. Every other statement in this section remains accepted and +> unchanged: a handler still never receives a Hono `Context`, the authorization store or evaluator, +> raw PostgreSQL access, configuration, or secrets. + A handler receives the unmodified Fetch `Request` (`c.req.raw`, so `Request.url`, headers, method, body, and a streaming request all behave normally, and the full query string remains available without a competing query abstraction) and a frozen context of exactly `moduleId`, the @@ -276,6 +286,10 @@ runs, since nothing about this design sandboxes it. ## Deferred +> **Cross-reference — see [ADR 0016](0016-module-database-access.md).** +> Module database or Drizzle access, listed immediately below as deferred, is now implemented. +> Every other item in this list remains deferred and unimplemented. + Module database or Drizzle access; module storage access; jobs and workflow/event automation; module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service; relationship-derived grant operations; reinstall/reactivation semantics for a permission or route diff --git a/docs/architecture/decisions/0016-module-database-access.md b/docs/architecture/decisions/0016-module-database-access.md new file mode 100644 index 0000000..fe3da72 --- /dev/null +++ b/docs/architecture/decisions/0016-module-database-access.md @@ -0,0 +1,227 @@ +# 0016: Module database access + +- Status: Accepted +- Date: 2026-07-25 +- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) and + [0015](0015-static-module-http-routing-and-runtime-lifecycle.md) + +## Context + +ADR 0007 and ADR 0008 already let a module own a Drizzle schema and a validated, checksummed +migration; ADR 0009 already applies those migrations transactionally. ADR 0015 let a module mount +permissioned HTTP routes and receive an authorized principal. None of these gave a module's +*executable* code — a `start`/`stop` lifecycle hook or a route handler — any supported way to +actually query or write the tables its own schema declares. A module wanting to persist or read +product data had no platform-sanctioned path to PostgreSQL at all. + +The platform already owns exactly one physical PostgreSQL pool per API runtime +(`apps/api/src/database.ts`), wrapped in typed Drizzle views for Core's own identity and +authorization schemas (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Handing a +module that raw pool, a connection string, or an independently constructed Drizzle instance would +either leak platform connection infrastructure into module code or require a second, competing +pool — both rejected by the same reasoning ADR 0006 and ADR 0015 already applied to capability +registration and HTTP routing. This ADR records the third typed view over that one pool, and the +runtime-services boundary that delivers it to trusted module code without exposing the pool +itself. + +## Decision + +### `ModuleDatabase`: a restricted, typed view over `NodePgDatabase` + +`packages/contracts/src/module-database.ts` defines `ModuleDatabase` as +`Pick` — derived from Drizzle's own `node-postgres` database type +with `Pick`, not hand-declared, so every method keeps Drizzle's own table-aware argument and +result inference exactly as-is. A module calling `database.select().from(table)` or +`database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller +would; no `any` or `unknown` erasure exists anywhere in the public type. `ModuleDatabase` carries +no schema generic: a module brings its own Drizzle table objects to each call, so there is no +global relational-query object spanning every installed module's tables for this type to expose. + +The type excludes everything else `NodePgDatabase` or the `drizzle(...)` factory otherwise +carries: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state), +`query` (a schema-bound relational query builder), `$cache`, `$count`, `$with`, `with`, and +`refreshMaterializedView`. There is no connection string, pool, session, or dialect object +reachable through the type. + +### The facade physically closes the same gaps at runtime, not only in the type system + +`core/src/modules/database.ts`'s `createModuleDatabaseView(pool)` does not return the +`drizzle(pool)` instance directly. Drizzle's own query methods are plain prototype methods that +read `this.session`/`this.dialect` internally, not arrow-function instance properties, so handing +that instance to a module directly would both break the moment a module destructured a method off +it (`const { select } = database` loses `this`) and — more importantly — would leave every +non-public property physically reachable on the object at runtime, reachable to a module written +in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not merely hidden +by TypeScript. `createModuleDatabaseView` instead builds a fresh, `Object.freeze`d object with +each of the eight operations explicitly bound to the underlying Drizzle instance: only those eight +exist on the object at all, each correctly bound, and nothing else does. Building this view +performs no query and no migration — `drizzle(pool)` only wraps the given client, synchronously +and without I/O, the same construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView` +already rely on. Core never constructs a `pg.Pool` itself; `apps/api/src/database.ts` remains the +one place that does, and `createModuleDatabaseView` wraps the pool it is given. + +### `ModuleRuntimeServices` is the one typed boundary carrying it into trusted module code + +`ModuleRuntimeServices` (`packages/contracts/src/module-database.ts`) currently contains exactly +one field: `database: ModuleDatabase`. It is frozen, and it is never handed to a module's +`register` hook — `ModuleRegistrationContext` (ADR 0006, extended by ADR 0015 with `http`) gains no +`services` field at all, so registration remains exactly as declarative and database-free as +originally decided. `ModuleRuntimeContext` (the shared `start`/`stop` context) and +`ModuleRouteHandlerContext` (ADR 0015's authorized-handler context, now exactly `moduleId`, +`principal`, `params`, `services`) both gain a `services` field instead. For a given API runtime, +`start`, `stop`, and every authorized HTTP handler receive the *same* `ModuleRuntimeServices` +object — the same object identity, not a fresh one built per hook or per request — so a module +never needs to stash a database reference in module-global state to share it between lifecycle +code and its own route handlers; the platform simply provides it directly, every time, to every +authorized call site. + +### One pool, one facade, one services object, per API runtime + +`apps/api/src/server.ts`'s `startApi` composes in a fixed order: discover the module inventory once +and compose the permission registry from it; construct the one runtime `pg.Pool` +(`createApiDatabase`, wrapping identity, authorization, and module views over it); construct the +frozen `ModuleRuntimeServices` from that same pool's module view; construct Identity, the +authorization store, and the evaluator; start the already-discovered module runtime, passing it +that services object; create the Hono application and mount the started runtime's routes, passing +the identical services object; bind the HTTP server. `startDiscoveredModuleRuntime` threads the +same services object into `ModuleRuntimeContext` for every module's `start` and `stop`; +`mountModuleHttpRoutes` threads the identical object into every authorized handler's +`ModuleRouteHandlerContext`. No second pool, no second facade, and no second services object is +ever constructed for one running API. A services/Identity/Authorization construction failure +closes the one pool and starts no module; a module-runtime failure closes the pool without binding +a server; an app-construction or bind failure stops the module runtime, then closes the pool — +matching the cleanup-ordering discipline ADR 0015 already established, now additionally covering +the services object's own dependencies. + +### Shutdown remains drain → stop → close, now load-bearing for in-flight database use + +Normal shutdown is unchanged from ADR 0015: drain and close the HTTP listener first, so no new or +in-flight request can reach a module handler while the module runtime is stopping; stop the module +runtime next — a module's `stop` hook may still use `services.database`, which is safe because the +pool has not yet closed; only then close the shared pool, so no module code can run against it +afterward. This ordering was already correct under ADR 0015; it is now additionally load-bearing, +because a `stop` hook doing real database work depends on the pool still being open when it runs. +Shutdown remains memoized — one shared promise every concurrent or later caller awaits — and a +module-stop failure still does not prevent pool closure, though (unlike a startup-cleanup path) it +is not swallowed: it remains a genuine result a caller observes. + +### Module schemas and migrations are unchanged; the facade is not a migration tool + +A module continues to own and import its own Drizzle table definitions (ADR 0007) and its own +generated, checksummed migration (ADR 0008), applied transactionally and explicitly (ADR 0009). +`ModuleDatabase` exposes no migration operation, and nothing in the module runtime infers a +schema, creates a table automatically, or runs a migration as a side effect of module discovery, +registration, start, or an HTTP request. A module's schema and migration changes still follow the +existing generation, validation, policy, and deployment flow unchanged; they must be applied, +exactly as before, before starting module code that depends on the new shape. + +## Trust and security boundary + +Modules remain trusted, compiled backend plugins — the same trust model ADR 0006 established for +capability registration and ADR 0015 extended to HTTP handlers. `ModuleDatabase` is a deliberately +restricted *integration* boundary, not a security sandbox: it exists to keep a module's code +Drizzle-typed and decoupled from how the platform physically connects to PostgreSQL, not to defend +against a malicious module, which already runs arbitrary code in this process and could reach +PostgreSQL through other means regardless of what this facade does or does not expose. It provides +no tenant isolation, no PostgreSQL-role-level separation between modules, and no runtime +enforcement that one module's code only ever queries its own tables — table ownership is an +architectural and migration-policy convention (ADR 0007's naming/prefix rules), not something this +facade inspects or restricts at the SQL level; `execute` in particular lets a trusted module issue +arbitrary SQL, exactly as `select`/`insert`/`update`/`delete`/`transaction` already let it construct +arbitrary queries against any table object it imports. Authorization still fully protects the HTTP +path into that trusted code: `requirePermission` and, for state-changing methods, +`requireTrustedOrigin` (ADR 0013, ADR 0015) complete before a handler — and therefore before +`services.database` is ever reachable — runs. A handler-thrown database error, caught or not, +remains sanitized by the existing module-handler boundary (ADR 0015): an uncaught failure becomes +exactly `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` and no SQL, +PostgreSQL message or code, table/constraint name, stack, connection detail, or filesystem path; +this ADR introduces no second, database-specific public error contract. + +## Consequences + +### Positive + +- A module can persist and query its own product data with full Drizzle type inference, without + ever owning a connection, a pool, or connection configuration. +- The platform still owns exactly one physical pool and one shutdown lifecycle per API runtime; + adding module database access did not require a second one. +- Lifecycle hooks and HTTP handlers share one consistent services boundary + (`ModuleRuntimeServices`), extensible the same deliberate way in the future. +- Registration remains declarative and side-effect-free, preserving every existing discovery, + Doctor, and `modules:validate` guarantee (none of them constructs the facade or executes module + code). + +### Tradeoffs + +- Adding a new Drizzle operation to the module-facing surface requires deliberately extending + `ModuleDatabase`'s `Pick`, not merely relying on whatever `NodePgDatabase` happens to expose. +- A trusted module can still issue arbitrary SQL through `execute`; this facade does not, and does + not claim to, prevent that. +- Table ownership remains a naming/migration-policy convention, not a runtime-enforced sandbox — + the same tradeoff ADR 0007 already accepted for schema ownership. +- Static installation and API-restart requirements are unchanged: a module's database access, like + its routes and permissions, only takes effect on the next restart. +- A module's migration must be applied before starting code that depends on it; the platform does + not infer or run it automatically, so an operator error here surfaces as a real database error + from the module's own code, sanitized like any other handler failure. + +## Rejected alternatives + +- **Exposing `pg.Pool` or `$client` directly** was rejected: it would leak platform connection + infrastructure into module code and make every module responsible for connection lifecycle the + platform already owns correctly. +- **Supplying `DATABASE_URL` to modules** was rejected for the same reason, plus the operational + risk of a module opening its own competing connections outside the platform's one validated pool. +- **One pool per module** was rejected: it would multiply connection overhead and configuration + surface for no isolation benefit this facade does not already provide at the type level, and + contradicts the one-pool-per-runtime model ADR 0015 already established. +- **Letting a module construct its own Drizzle instance** was rejected: it would reintroduce + exactly the raw-pool/connection-string exposure this ADR exists to avoid. +- **Database access during `register`** was rejected: registration remains declarative — the same + reasoning ADR 0006 already applied to capability provision — and a database-free registration + hook can never have a partially-open connection to clean up if discovery aborts. +- **Module-global service assignment** (a module stashing `services` in a top-level variable from + `start` for its handlers to read later) was rejected as the *documented* pattern: the platform + provides the identical object directly to every authorized call site, so no module needs to + invent its own storage for it. +- **Running migrations automatically at startup** was rejected: it would violate the existing, + explicit, operator-invoked migration model (ADR 0009) and risk running unreviewed DDL as a + side effect of an API restart. +- **A generic, string-keyed service locator** (`services.get("database")`) was rejected in favor of + a fixed, typed `ModuleRuntimeServices` shape: a locator would erase type information at exactly + the boundary this ADR exists to keep typed, and would let a module probe for services that do + not exist yet. +- **A global schema/query object spanning every installed module** (Drizzle's own schema-bound + `query` relational builder, extended to cover every module) was rejected: it would require the + platform to aggregate every module's schema into one object at runtime, reintroducing exactly the + cross-module coupling ADR 0007's per-module schema ownership already rejected. +- **SQL parsing, rewriting, or query interception as a runtime isolation boundary** was rejected: + modules are trusted compiled code already capable of running arbitrary queries through `execute`; + a parsing layer would be both bypassable and a false promise of a security boundary this facade + does not attempt to provide. + +## Testing evidence + +- Unit coverage (contracts, Module SDK, Core, `apps/api`) proves: the public `ModuleDatabase` + surface preserves Drizzle's table-aware inference with no `any`; raw pool/client/session/dialect + properties are absent from the type and, separately, from the constructed facade at runtime; + every forwarded method remains correctly bound when destructured; `register` receives no + services/database field; `start`/`stop` receive the same frozen services object; a module HTTP + handler's database is reference-equal to its module's lifecycle database; denied, + trusted-origin-rejected, and `OPTIONS` requests never touch the database fake; a zero-module + runtime never constructs or touches a database; startup/shutdown cleanup ordering holds under + every failure path exercised. +- A real installed-module PostgreSQL integration fixture (`core/tests/fixtures/ + module-database-integration/db-facade/`, exercised from `apps/api/tests/ + module-database.integration.ts`) proves the same guarantees against a real, disposable + PostgreSQL database: the module's table does not exist before its migration is explicitly + applied; constructing the pool and facade performs no DDL; starting the API never runs or + regenerates a migration; a real `start` hook write is visible to a later authorized HTTP read + through the identical facade object; a real authorized mutation persists and a later read + observes it, gated by permission enforcement and trusted-Origin checking exactly as ADR 0015 + requires; a real committed transaction persists and a real rollback (a deliberate unique- + constraint violation) leaves no partial mutation; a module deliberately catching a real + PostgreSQL failure returns its own response untouched, while an uncaught real failure sanitizes + to the existing `MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; a + `stop` hook's write completes and is independently observable before the runtime's pool closes. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index ce1d199..a2eca9f 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -24,3 +24,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted - [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted - [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted +- [0016: Module database access](0016-module-database-access.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index ff85b4a..f4e1772 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -95,12 +95,29 @@ identical lowest-privilege baseline status, and signup order never grants elevat [ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and [the API guide](../development/api.md), and [the identity development guide](../development/identity.md) for the underlying schema and -contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out; it -is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or -permission. +contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out. -The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline / -relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap -command. Other target architecture not yet implemented includes production approval-provider -composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action -contracts, dashboard, Builder, and reference feature modules. +Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for +platform owners, explicit grants, and relationship-derived grants, plus an immutable permission +registry — and an explicit, single-use, auditable platform-owner bootstrap command, plus reusable +Hono enforcement and an `/api/v1/grants` HTTP API in `apps/api`. See +[ADR 0013](decisions/0013-core-authorization-and-owner-bootstrap.md) and +[the authorization guide](../development/authorization.md). + +Phase 5A wired module-declared permissions into that same authorization system — a version-3 +manifest, one composed platform permission registry spanning Core's own declarations and every +installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a +module mount its own framework-neutral HTTP routes in production, under +`/api/v1/modules/`, enforced and response-sanitized exactly like Core's own routes. This +branch completes Phase 5C: typed module database access (`context.services.database`) for a +module's lifecycle hooks and authorized HTTP handlers, over the platform's one existing PostgreSQL +pool. See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md), +[ADR 0015](decisions/0015-static-module-http-routing-and-runtime-lifecycle.md), +[ADR 0016](decisions/0016-module-database-access.md), and the repository root +[README](../../README.md#current-status) for the complete, current status of every phase. + +The project continues finishing the module platform's backend foundations before Phase 6 Surface +work begins. Target architecture not yet implemented includes production approval-provider +composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource +and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are +scheduled by this document. diff --git a/docs/development/api.md b/docs/development/api.md index 9010016..0ec7f99 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -215,11 +215,15 @@ abuse-control design. permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`, `apps/api/src/platform-permissions.ts`. 2. Construct the one shared PostgreSQL pool. -3. Construct the Core identity runtime, authorization store, and evaluator. -4. Start the module runtime from that exact same discovered inventory - (`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step. -5. Create the Hono application, mounting the started module runtime's published routes. -6. Bind the HTTP server. +3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same + pool (see [Module database access](#module-database-access)). +4. Construct the Core identity runtime, authorization store, and evaluator. +5. Start the module runtime from that exact same discovered inventory + (`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the + services object every module's `start`/`stop` hook receives. +6. Create the Hono application, mounting the started module runtime's published routes and passing + the identical services object to every module HTTP handler. +7. Bind the HTTP server. Discovery and permission composition happen before any pool, Better Auth instance, module entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid @@ -257,9 +261,11 @@ are mounted under `/api/v1/modules/`, in the module runtime's publish (same permission check, response body stripped); a module cannot separately author `HEAD`. - **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and body all behave normally, and the full query string remains available) plus a frozen context of - exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters - (an empty frozen object for a route with none) — never a Hono `Context`, a database connection, - the authorization store, storage, jobs, configuration, or secrets. + exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an + empty frozen object for a route with none), and `services` — the same frozen + `ModuleRuntimeServices` object the module's own `start`/`stop` hooks received (see + [Module database access](#module-database-access)) — never a Hono `Context`, the authorization + store, storage, jobs, configuration, or secrets. - **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response` becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the caught value, a stack, the route's path or permission id, or the module id. A `Response` a @@ -276,6 +282,46 @@ are mounted under `/api/v1/modules/`, in the module runtime's publish registry: installing, removing, or editing a module's routes takes effect only on the next API restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`. +## Module database access + +`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized +HTTP handler a typed Drizzle query surface over its own tables — never during `register`, which +stays declarative and database-free. See +[Module development](module-development/README.md#database-access) for the module-authoring +contract; this section covers only how `apps/api` composes and wires it. + +- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase` + (`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`, + `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving + Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal + session/dialect state, no schema-bound relational `query` builder, and no connection string, + pool, or raw `pg` client — not merely hidden by the TypeScript type, but physically absent from + the constructed object at runtime (`core/src/modules/database.ts`'s `createModuleDatabaseView` + builds a frozen object with each operation explicitly bound, rather than handing out Drizzle's + own instance). +- **One pool, one facade, one services object.** `apps/api/src/database.ts`'s `createApiDatabase` + wraps the one shared PostgreSQL pool in three independent typed Drizzle views — identity, + authorization, and now `module` (a `ModuleDatabase`). `startApi` builds one frozen + `ModuleRuntimeServices` object (currently just `{ database }`) from that pool and passes the + *same* object to every module's `start` and `stop` hook and to every module HTTP handler for the + lifetime of one running API — never a fresh object per hook or per request, and never a second + pool or facade. +- **No database access without authorization.** A handler's context — and therefore + `services.database` — is unreachable until `requirePermission` (and, for a state-changing + method, `requireTrustedOrigin`) has already succeeded; a denied request, a rejected-Origin + request, and an `OPTIONS` preflight never invoke a module handler at all, so they never touch a + module's database. +- **Errors.** A handler-thrown database error is sanitized through the exact same + `MODULE_HANDLER_FAILED` boundary described above — there is no separate, database-specific public + error contract. A module that deliberately catches its own database failure may still return any + valid `Response` of its own choosing. +- **Migrations stay explicit.** Constructing the facade performs no query and no migration. + Module schema and migration changes still follow the existing generation, validation, policy, and + deployment flow (see [Module development](module-development/README.md#database-access)); nothing + in module discovery, registration, start, or an HTTP request infers a schema or creates a table + automatically. See [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the + complete design and its rejected alternatives. + ## Database connection limits `apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`, @@ -287,10 +333,10 @@ a query is already running on one. The operation timeout additionally sets `pg`' and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is -constructed, sanitized the same way an invalid connection string already is. Both the identity and -authorization domains read from this one pool through independent typed Drizzle views — see -[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool, -not two. +constructed, sanitized the same way an invalid connection string already is. The identity, +authorization, and module domains all read from this one pool through independent typed Drizzle +views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly +one pool, not two. ## Graceful shutdown @@ -300,19 +346,22 @@ shared promise the first time it is called: every caller, concurrent or sequenti exact same operation, and that operation runs exactly once no matter how many times `shutdown()` is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight requests finish; then stops the module runtime (so no module resource stops while a route handler -might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization -both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a -startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a -genuine result a caller should observe — but pool cleanup always still runs first, so a stop -failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is -extracted into `installShutdownSignalHandlers(running, exit)` — the same function production +might still be in flight) — a module's `stop` hook may still use `services.database` here, since +the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization, +and the module database facade all read from it — see +[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup +path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should +observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open, +and no module code can run against it afterward. The executable entry point's `SIGINT`/`SIGTERM` +wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a signal arrived" without sending a real process signal or invoking the real `process.exit`. A startup failure after the database pool was already created always closes that pool before the -failure propagates: identity/authorization construction failing (no module runtime was started -yet); the server failing to bind its listen address; or an app-construction failure, both of which -also stop the module runtime first, since it may have already started successfully. A module +failure propagates: module-services/identity/authorization construction failing (no module runtime +was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the +same pool); the server failing to bind its listen address; or an app-construction failure, both of +which also stop the module runtime first, since it may have already started successfully. A module load/register/start failure uses Core's own existing rollback (already-started modules stop, in reverse order, before the failure propagates) and then closes the pool — there is no separate runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one. @@ -354,10 +403,9 @@ Cookie: better-auth.session_token= {"kind":"anonymous"} ``` -## Next phase +## What's next -Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants, -an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and -reversible relationship-derived access itself. See [Authorization](authorization.md) for what this -pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a -Dashboard Surface, which still depends on an authorization model that is only now getting started. +See the repository root [README](../../README.md#current-status) and +[platform architecture](../architecture/platform-architecture.md#current-implementation-status) +for what has landed across every phase and what remains before Surface work begins. This document +tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve. diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 7f1954c..cfca816 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -396,15 +396,19 @@ Every response sets `Cache-Control: no-store`. `apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api` touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded -`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two -independent typed Drizzle views — `createIdentityDrizzleView(pool)` and -`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned -handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool -itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the -identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from -`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown, -and on any startup failure after the pool was already constructed (mirroring the existing -identity-only failure-cleanup path from [API](api.md#graceful-shutdown)). +`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with three +independent typed Drizzle views — `createIdentityDrizzleView(pool)`, +`createAuthorizationDrizzleView(pool)`, and `createModuleDatabaseView(pool)` (the `ModuleDatabase` +facade module code queries through `context.services.database`; see +[API](api.md#module-database-access) and +[Module development](../module-development/README.md#database-access)) — rather than three +separate pools. `close()` on the returned handle is the only thing that ever calls `pool.end()`; +no domain view owns or closes the pool itself. `apps/api/src/server.ts` calls `createApiDatabase` +once during `startApi()`, builds the identity runtime from `.identity`, the +`AuthorizationStore`/`AuthorizationEvaluator` from `.authorization`, and the frozen +`ModuleRuntimeServices` from `.module` off that same handle, and closes the handle exactly once — +on graceful shutdown, and on any startup failure after the pool was already constructed (mirroring +the existing identity-only failure-cleanup path from [API](api.md#graceful-shutdown)). The existing per-domain factories (`createIdentityDrizzleDatabase`, `createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are @@ -413,9 +417,9 @@ unaffected and remain available for standalone/testable use — the CLI bootstra the authorization schema and has no reason to share a pool with anything else. `apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one -`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization -usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers -there are or whether the failure happened during startup. +`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and +module database usage), and exactly one `pool.end()` call regardless of how many concurrent +`shutdown()` callers there are or whether the failure happened during startup. ## Tests diff --git a/docs/development/identity.md b/docs/development/identity.md index 8b32990..f1ba1c5 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -169,9 +169,10 @@ rejected the same sanitized way an invalid connection string already is. ## What is not operational yet No email verification, password recovery, invitation flow, email delivery, social or OAuth -provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model +provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are** operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why -public registration is intentional and grants no elevation. The next expected slice is Phase 4C: -Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard -Surface, which still depends on an authorization model that does not exist yet. +public registration is intentional and grants no elevation. Core's authorization foundation +(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is +covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still +depends on an authorization model that only now exists and has not yet been built on. diff --git a/docs/development/testing.md b/docs/development/testing.md index aba4e2e..0b56345 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -97,6 +97,23 @@ database failure and asserts, against actual `console.error`/`warn`/`log` output Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never emits a raw exception, SQL, connection detail, or the rejected request's origin. +A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same +`pnpm test:migrations:integration` run) proves `context.services.database` against a real, +installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the +repository's live `modules/` directory, and invisible to normal discovery/Doctor/`modules:validate` +unless a test explicitly points discovery at it): the module's own table does not exist until its +migration is explicitly applied, and constructing the pool or facade never creates it; `start`, +`stop`, and an authorized HTTP handler all receive the identical `ModuleRuntimeServices` object, +proven by reference equality; a real authorized mutation persists and a later read observes it, +gated by the same permission/trusted-Origin enforcement every other module route uses; a real +committed transaction persists and a real rollback (a deliberate unique-constraint violation) +leaves no partial mutation; a module deliberately catching a real PostgreSQL failure returns its +own response untouched, while an uncaught one sanitizes to the existing +`MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; and a `stop` hook's write +completes and is independently verifiable, through the harness's own administrative connection — +never the API's own pool — before that pool closes. See +[ADR 0016](../architecture/decisions/0016-module-database-access.md). + CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add the following pull-request status context to `main` branch protection: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index d465eeb..2c2dbdb 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -157,9 +157,9 @@ export default defineModuleRuntime({ capabilities.provide("calendar.events", calendarEvents); }, - async start({ capabilities }) { + async start({ capabilities, services }) { const people = capabilities.resolve("people.records"); - // Acquire runtime resources here. + await services.database.insert(events).values({ id: "seed", title: "First event" }); }, async stop() { @@ -170,8 +170,11 @@ export default defineModuleRuntime({ ID and version are intentional build-artifact identity markers and must match the manifest. Core rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its -own capabilities and the capability dependencies declared in its manifest. The runtime context is -deliberately limited to the module ID and capability access. +own capabilities and the capability dependencies declared in its manifest. The `register` context +is deliberately limited to `moduleId`, capability access, and the HTTP route registrar (below) — +never `services`. `start` and `stop` share one context shape and both additionally receive +`services` — the same frozen `ModuleRuntimeServices` object for the lifetime of one running API, +currently exposing only `database` — see [Database access](#database-access). Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and @@ -235,10 +238,12 @@ method and path (or two paths that are identical once every parameter is treated placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route. The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's -already-authorized `EkklesiaPrincipal`, and the immutable decoded path parameters — never a database -connection, authorization store, grant/platform-owner state, storage, jobs, configuration, secrets, -a framework-specific request context, or a raw Better Auth session. The full query string remains -available through `Request.url`; there is no separate query or body abstraction. +already-authorized `EkklesiaPrincipal`, the immutable decoded path parameters, and `services` — the +same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently +exposing only `database` (see [Database access](#database-access)) — never an authorization store, +grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request +context, or a raw Better Auth session. The full query string remains available through +`Request.url`; there is no separate query or body abstraction. Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production @@ -344,6 +349,73 @@ destructive behavior. Review those properties explicitly. Core is the only migra follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or falls back to non-transactional execution. +## Database access + +`context.services.database` — available in `start`, `stop`, and every authorized route handler, +never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`, +`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, +preserving Drizzle's own table-aware argument and result inference. It is the platform's only +supported way for module code to reach PostgreSQL: + +```ts +import { defineModuleRuntime } from "@ekklesia/module-sdk"; +import { events } from "./schema/tables.js"; + +export default defineModuleRuntime({ + id: "calendar", + version: "0.1.0", + + register({ http }) { + http.route({ + method: "GET", + path: "/events", + permission: "calendar.events.read", + async handler(_request, context) { + const rows = await context.services.database + .select({ id: events.id, title: events.title }) + .from(events); + return Response.json(rows); + }, + }); + }, +}); +``` + +Import your module's own table objects (`src/schema/tables.js` — the same declarations +[Drizzle schema definition](#drizzle-schema-definition) already covers) exactly as you would with +an independently constructed Drizzle instance; the only difference is that `services.database`, +not a client you built yourself, executes the query. Never: + +- Construct a `Pool`, a `Client`, or a Drizzle instance in module code. +- Read `DATABASE_URL` or any other connection configuration. +- Access `$client`, a session, or a dialect object — `ModuleDatabase` exposes none of them, and not + merely at the type level: the constructed facade physically has no such property. +- Stash `services` (or `services.database`) in a module-global variable. The platform provides the + identical object directly to `start`, `stop`, and every authorized handler; there is nothing to + cache. +- Run or generate a migration from `register`, `start`, `stop`, or a route handler. Schema and + migration changes remain [explicit deployment work](#drizzle-migrations) — generate, review, + commit, and apply them the same way as any other module migration, *before* starting code that + depends on the new shape. Nothing in discovery, registration, or startup infers a schema or + creates a table automatically. + +A handler's database error is sanitized through the same `500 {"code":"MODULE_HANDLER_FAILED"}` +boundary as any other handler failure (see [HTTP routes](#http-routes)) — there is no separate, +database-specific error contract. Catch a failure yourself if your route should return something +more specific; an uncaught one is sanitized automatically. + +`ModuleDatabase` is an integration boundary for trusted, compiled backend module code, not a +sandbox: it exists so your module never has to construct or configure a PostgreSQL connection +itself, not to defend against malicious code, since a module already runs arbitrary code in this +process. It provides no tenant isolation and does not stop your own code from querying a table it +imports; `execute` in particular lets trusted code run arbitrary SQL, exactly as every other +operation already lets it construct arbitrary queries. Keep querying only your own module's tables +— the same convention [Drizzle schema definition](#drizzle-schema-definition) already establishes +for imports, just extended to runtime queries. See +[API](../development/api.md#module-database-access) for how `apps/api` composes and wires the +facade, and [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the complete +design. + ## Discovery and lifecycle From the repository root, run: diff --git a/templates/module/README.md b/templates/module/README.md index 1fb6950..db25c44 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -2,8 +2,11 @@ This is the smallest supported backend-only Ekklesia module. It proves the static manifest, compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a -generated module-owned migration, and logical storage-bucket declarations. Migration execution, -API routing, and storage provisioning are intentionally not included. +generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP +route (`GET /records`, declared in `register`) that reads that table through +`context.services.database` — the platform-provided `ModuleDatabase` facade, never a +module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage +provisioning are intentionally not included. To create a module inside this repository: @@ -14,7 +17,10 @@ To create a module inside this repository: 4. Keep the manifest capability versions and dependency ranges aligned with the contracts the module actually implements and consumes. 5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version - must match the manifest; do not duplicate the rest of the manifest in TypeScript. + must match the manifest; do not duplicate the rest of the manifest in TypeScript. Declare HTTP + routes only in `register`, against a permission the manifest already declares; `start`, `stop`, + and every route handler — never `register` — receive `context.services.database`, a typed + `ModuleDatabase` view over the platform's one PostgreSQL pool. 6. Replace the pure Drizzle declarations in `src/schema/tables.ts` and keep its table prefix aligned with the module ID. Keep the `defineModuleSchema` ownership wrapper in `src/schema/index.ts` aligned too, and never import another module's schema. @@ -29,7 +35,9 @@ To create a module inside this repository: Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces. Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`, -and release them in `stop`. +and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a +raw `pg` client — `context.services.database` is the only supported way to query this module's own +tables, and it is never available during `register`. The migration directory uses the pinned stable Drizzle Kit `0.31.10` PostgreSQL journal and snapshot format. Migrations are forward-only and may modify only this module's objects. Offline diff --git a/templates/module/src/schema/tables.ts b/templates/module/src/schema/tables.ts index d6cef3e..63a4d71 100644 --- a/templates/module/src/schema/tables.ts +++ b/templates/module/src/schema/tables.ts @@ -1,6 +1,7 @@ -import { pgTableCreator, text, uuid } from "drizzle-orm/pg-core"; +import { createModuleTable } from "@ekklesia/module-sdk"; +import { text, uuid } from "drizzle-orm/pg-core"; -const moduleTable = pgTableCreator((localName) => `example_${localName}`); +const moduleTable = createModuleTable("example"); export const records = moduleTable("records", { id: uuid("id").primaryKey(), diff --git a/templates/module/src/server/index.ts b/templates/module/src/server/index.ts index 3fe1ecc..0b00625 100644 --- a/templates/module/src/server/index.ts +++ b/templates/module/src/server/index.ts @@ -1,4 +1,5 @@ import { defineModuleRuntime } from "@ekklesia/module-sdk"; +import { records } from "../schema/tables.js"; export const recordsCapability = Object.freeze({ kind: "example-records", @@ -7,7 +8,25 @@ export const recordsCapability = Object.freeze({ export default defineModuleRuntime({ id: "example", version: "0.1.0", - register({ capabilities }) { + register({ capabilities, http }) { capabilities.provide("example.records", recordsCapability); + + // The accepted module database boundary: `context.services.database` — provided only to an + // authorized handler, never to `register` — is this module's one way to reach PostgreSQL. It + // is a fully typed Drizzle query surface over the module's own table (`../schema/tables.js`), + // never a raw `pg` client, a connection string, or a Drizzle instance this module built + // itself. `example.records.read` is declared in `ekklesia.module.json`, exactly like every + // other permission a module route enforces. + http.route({ + method: "GET", + path: "/records", + permission: "example.records.read", + async handler(_request, context) { + const rows = await context.services.database + .select({ id: records.id, value: records.value }) + .from(records); + return Response.json(rows); + }, + }); }, }); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 32b429f..4af5b8a 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -1,7 +1,12 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { + ANONYMOUS_PRINCIPAL, type CapabilityId, + type ModuleDatabase, + type ModuleRouteDefinitionInput, + type ModuleRouteHandlerContext, + type ModuleRuntimeServices, parseDrizzleMigrationJournal, parseModuleManifest, parseModuleMigrationPolicy, @@ -12,6 +17,38 @@ import { describe, expect, it } from "vitest"; import moduleSchema, { records } from "../src/schema/index.js"; import moduleRuntime, { recordsCapability } from "../src/server/index.js"; +/** A small typed fake `ModuleDatabase` — no PostgreSQL required for this unit test, which only + * proves the example handler queries through the facade and shapes its response correctly. */ +function fakeModuleDatabase(rows: readonly { id: string; value: string }[]): ModuleDatabase { + const selectBuilder = { from: () => Promise.resolve(rows) }; + return { select: () => selectBuilder } as unknown as ModuleDatabase; +} + +async function registerAndCaptureRoutes(): Promise<{ + provided: Map; + routes: ModuleRouteDefinitionInput[]; +}> { + const provided = new Map(); + const routes: ModuleRouteDefinitionInput[] = []; + await moduleRuntime.register?.({ + capabilities: { + provide(capabilityId, implementation) { + provided.set(capabilityId, implementation); + }, + resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined { + return undefined; + }, + }, + http: { + route(definition) { + routes.push(definition); + }, + }, + moduleId: "example", + }); + return { provided, routes }; +} + describe("module template", () => { it("ships a valid backend-only manifest", async () => { const manifestUrl = new URL("../ekklesia.module.json", import.meta.url); @@ -59,27 +96,41 @@ describe("module template", () => { expect(sql.toString("utf8")).toContain('CREATE TABLE "example_records"'); }); - it("exports the typed runtime definition used by the compiled server entrypoint", async () => { - const provided = new Map(); - await moduleRuntime.register?.({ - capabilities: { - provide(capabilityId, implementation) { - provided.set(capabilityId, implementation); - }, - resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined { - return undefined; - }, - }, - http: { - route() { - throw new Error("The template does not register any HTTP route."); - }, - }, - moduleId: "example", - }); + it("exports the typed runtime definition used by the compiled server entrypoint, registering its one example route database-free", async () => { + const { provided, routes } = await registerAndCaptureRoutes(); expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" }); expect(provided.get("example.records")).toBe(recordsCapability); + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ + method: "GET", + path: "/records", + permission: "example.records.read", + }); + }); + + it("queries the module's own table through context.services.database in the example route handler", async () => { + const { routes } = await registerAndCaptureRoutes(); + const route = routes[0]; + if (!route) { + throw new Error("Expected the example route to be registered."); + } + + const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }]; + const services: ModuleRuntimeServices = Object.freeze({ + database: fakeModuleDatabase(fakeRows), + }); + const context: ModuleRouteHandlerContext = Object.freeze({ + moduleId: "example", + params: Object.freeze({}), + principal: ANONYMOUS_PRINCIPAL, + services, + }); + + const response = await route.handler(new Request("http://localhost/records"), context); + + expect(response).toBeInstanceOf(Response); + expect(await response.json()).toEqual(fakeRows); }); it("exports a namespaced module-owned Drizzle schema", () => {