Merge pull request 'feat: establish the module product contract' (#15) from phase-6a/module-product-contract into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/15
This commit was merged in pull request #15.
This commit is contained in:
@@ -12,10 +12,12 @@ monorepo:
|
|||||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
||||||
feature module, a surface, or an app.
|
feature module, a surface, or an app.
|
||||||
- **Modules** (`modules/*`, template at `templates/module`) are backend-only capability packages.
|
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||||
They own their own data and migrations, declare capabilities/permissions through
|
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never contain UI or import Core,
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||||
another module, or a surface directly.
|
surface directly. Per ADR 0021 a module may also own its own UI and declare a compiled
|
||||||
|
`entrypoints.ui` artifact; no runtime hosts that entrypoint yet, and `pnpm boundaries` still
|
||||||
|
rejects UI source files and UI framework dependencies inside a module package.
|
||||||
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client
|
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client
|
||||||
contracts only, never Core or module internals directly.
|
contracts only, never Core or module internals directly.
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -33,10 +33,13 @@ pnpm infrastructure:config
|
|||||||
- Expose supported entry points through package `exports`; do not import another package's
|
- Expose supported entry points through package `exports`; do not import another package's
|
||||||
private source paths.
|
private source paths.
|
||||||
- Use `workspace:` ranges for internal package dependencies.
|
- Use `workspace:` ranges for internal package dependencies.
|
||||||
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other
|
- Module packages cannot contain React, mobile, stylesheet, or other UI source files, and
|
||||||
UI source files.
|
`pnpm boundaries` enforces this. ADR 0021 establishes that a module may own its own UI, but the
|
||||||
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a
|
authoring model — and therefore which parts of that rule change — is a later Phase 6 decision.
|
||||||
surface.
|
Until it lands, a module's optional `entrypoints.ui` artifact must be framework-neutral compiled
|
||||||
|
output, and nothing hosts it yet.
|
||||||
|
- The containing application shell, navigation, global theme, and platform policy belong to Core and
|
||||||
|
the host application, never to a module.
|
||||||
- Add or update tests when changing a contract or architectural rule.
|
- Add or update tests when changing a contract or architectural rule.
|
||||||
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Ekklesia
|
# Ekklesia
|
||||||
|
|
||||||
Ekklesia is an open, modular church technology platform for composing backend capabilities
|
Ekklesia is an open, modular church technology platform. Each module is an independently versioned
|
||||||
into independently developed dashboards, websites, mobile apps, kiosks, displays, and other
|
product that owns its domain behavior, data, and its own UI, composed into dashboards, websites,
|
||||||
surfaces.
|
mobile apps, kiosks, displays, and other surfaces.
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Phase 4C (Core authorization), Phase 5A (module authorization integration), Phas
|
|||||||
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
|
transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
|
||||||
against an independent security audit) is complete: all five passes have landed and the final
|
against an independent security audit) is complete: all five passes have landed and the final
|
||||||
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
independent whole-repository re-audit has closed, under the current trusted-contributor,
|
||||||
not-yet-deployed threat model — and Phase 6 (Surface work) is unblocked.** The operational
|
not-yet-deployed threat model — and Phase 6 is unblocked and underway.** The operational
|
||||||
requirements that remain are prerequisites for two specific *future* activities — executing
|
requirements that remain are prerequisites for two specific *future* activities — executing
|
||||||
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
|
||||||
pass 5 closure below and
|
pass 5 closure below and
|
||||||
@@ -60,9 +60,9 @@ on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint
|
|||||||
accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
|
accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
|
||||||
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
||||||
|
|
||||||
Phase 5A wires module-declared permissions into that same authorization system: a version-3 module
|
Phase 5A wires module-declared permissions into that same authorization system: the module manifest
|
||||||
manifest declares permissions as complete `{id, baseline, description}` declarations (never a bare
|
(version 3 at the time, version 4 today) declares permissions as complete
|
||||||
string), and `apps/api` discovers every installed module and composes one immutable platform
|
`{id, baseline, description}` declarations (never a bare string), and `apps/api` discovers every installed module and composes one immutable platform
|
||||||
permission registry — Core's own declarations plus every module's — before constructing its
|
permission registry — Core's own declarations plus every module's — before constructing its
|
||||||
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
|
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
|
||||||
enforceable and grantable through the exact same `requirePermission` middleware and
|
enforceable and grantable through the exact same `requirePermission` middleware and
|
||||||
@@ -218,6 +218,31 @@ development proceeds under the current trusted-contributor, not-yet-deployed mod
|
|||||||
and job-token authority remain unverified and are not described as isolated. See
|
and job-token authority remain unverified and are not described as isolated. See
|
||||||
[Security model and deployment trust boundaries](docs/development/security.md).
|
[Security model and deployment trust boundaries](docs/development/security.md).
|
||||||
|
|
||||||
|
Phase 6A establishes the **module product contract** and nothing else yet. Ekklesia's modules are no
|
||||||
|
longer backend-only capability packages: a module is an independently versioned *product* that owns
|
||||||
|
its domain behavior, data, permissions, routes, migrations, and — new in this pass — **its own UI**.
|
||||||
|
Core keeps the containing application shell and every platform policy (authentication, authorization
|
||||||
|
enforcement, platform context, top-level routing, navigation, global theme, error boundaries,
|
||||||
|
trusted-origin/CORS). Modules stay **flat peers**: `video`, `livestream`, and `announcements` are
|
||||||
|
independent first-class modules with no parent/child relationship, and a Dashboard folder such as
|
||||||
|
`Media` is presentation configuration an administrator can reorganize, never module architecture and
|
||||||
|
never a dependency. The long-term goal is one module implementation hosted by Ekklesia *and*, later,
|
||||||
|
by a standalone application — never two codebases for one domain. See
|
||||||
|
[ADR 0021](docs/architecture/decisions/0021-modules-as-hostable-products.md).
|
||||||
|
|
||||||
|
**What this pass actually ships is the static contract only.** `manifestVersion: 4` adds one
|
||||||
|
optional field, `entrypoints.ui`, a package-relative path to a compiled ESM artifact validated
|
||||||
|
during discovery exactly like `server` and `schema` — inside the package, real file, no source
|
||||||
|
TypeScript. Discovery stays inert: **nothing imports, mounts, or renders that entrypoint**, exactly
|
||||||
|
like the long-declared `entrypoints.jobs`. Upgrading a backend-only version 3 manifest is a
|
||||||
|
one-character edit; version 3 is rejected outright rather than reinterpreted, as version 2 was
|
||||||
|
before it. **The Dashboard shell, a UI runtime or renderer, any UI framework choice, navigation
|
||||||
|
registration, Dashboard grouping, a standalone host, a module installer or marketplace, hot-loading,
|
||||||
|
and every feature module do not exist.** `pnpm boundaries` also still enforces
|
||||||
|
`modules-do-not-own-ui`, so module UI cannot yet be authored with a UI framework — choosing that
|
||||||
|
authoring model is the next pass's first question. Restart-based discovery, explicit
|
||||||
|
operator-invoked migrations, and the trusted compiled-module model are unchanged.
|
||||||
|
|
||||||
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
|
||||||
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
|
||||||
declarations, typed facades, and frozen contexts are correctness boundaries, not a sandbox. Ekklesia
|
declarations, typed facades, and frozen contexts are correctness boundaries, not a sandbox. Ekklesia
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ async function createGatedRouteModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -335,7 +335,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -200,7 +200,7 @@ function fakeDiscoveredModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export interface ResolvedModuleEntrypoints {
|
|||||||
readonly schema?: string;
|
readonly schema?: string;
|
||||||
readonly migrations?: string;
|
readonly migrations?: string;
|
||||||
readonly jobs?: string;
|
readonly jobs?: string;
|
||||||
|
/** A module's declared presentation artifact (ADR 0021), resolved and validated exactly like the
|
||||||
|
* other entrypoints. Discovery only proves the file exists, is compiled ESM, and stays inside the
|
||||||
|
* module package — nothing in this repository imports, evaluates, mounts, or renders it yet. */
|
||||||
|
readonly ui?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
||||||
@@ -243,8 +247,11 @@ async function resolveEntrypoints(
|
|||||||
if (relativeEntrypoint === undefined) {
|
if (relativeEntrypoint === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// `ui` joins `schema`/`server` here: a presentation entrypoint is a build output like any
|
||||||
|
// other module artifact, so source TypeScript is rejected at discovery rather than relied upon
|
||||||
|
// to be compiled later (ADR 0006's compiled-artifact rule, extended by ADR 0021).
|
||||||
if (
|
if (
|
||||||
["schema", "server"].includes(entrypointName) &&
|
["schema", "server", "ui"].includes(entrypointName) &&
|
||||||
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
||||||
) {
|
) {
|
||||||
issues.push({
|
issues.push({
|
||||||
@@ -323,6 +330,7 @@ async function resolveEntrypoints(
|
|||||||
...(resolved.schema ? { schema: resolved.schema } : {}),
|
...(resolved.schema ? { schema: resolved.schema } : {}),
|
||||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||||
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
|
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
|
||||||
|
...(resolved.ui ? { ui: resolved.ui } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function createModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 3,
|
"manifestVersion": 4,
|
||||||
"id": "db-facade",
|
"id": "db-facade",
|
||||||
"name": "Database Facade Integration Test Module",
|
"name": "Database Facade Integration Test Module",
|
||||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ interface ModuleFixtureOptions {
|
|||||||
manifest?: Record<string, unknown>;
|
manifest?: Record<string, unknown>;
|
||||||
packageJson?: Record<string, unknown>;
|
packageJson?: Record<string, unknown>;
|
||||||
serverSource?: string;
|
serverSource?: string;
|
||||||
|
/** Extra package-relative files to write before discovery runs, so a fixture can publish a
|
||||||
|
* compiled UI artifact (or a deliberately uncompiled one) alongside its server entrypoint. */
|
||||||
|
extraFiles?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
@@ -31,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -73,6 +76,12 @@ async function createModule(
|
|||||||
options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
for (const [relativePath, contents] of Object.entries(options.extraFiles ?? {})) {
|
||||||
|
const target = path.join(moduleDirectory, relativePath);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, contents);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoveryError(
|
async function discoveryError(
|
||||||
@@ -585,4 +594,115 @@ describe("module discovery", () => {
|
|||||||
|
|
||||||
expect(firstError.issues).toEqual(secondError.issues);
|
expect(firstError.issues).toEqual(secondError.issues);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("module-owned UI entrypoint (ADR 0021)", () => {
|
||||||
|
function manifestWithUi(id: string, ui: string): Record<string, unknown> {
|
||||||
|
const manifest = manifestFor(id);
|
||||||
|
manifest.entrypoints = { server: "./dist/server/index.js", ui };
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("resolves a declared compiled UI entrypoint to a canonical path inside the package", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "announcements", {
|
||||||
|
manifest: manifestWithUi("announcements", "./dist/ui/index.js"),
|
||||||
|
extraFiles: { "dist/ui/index.js": "export default {};" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [discovered] = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(discovered?.entrypoints.ui).toBe(
|
||||||
|
path.join(discovered?.directory ?? "", "dist/ui/index.js"),
|
||||||
|
);
|
||||||
|
expect(discovered?.entrypoints.server).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the resolved UI entrypoint entirely for a backend-only module", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "roster");
|
||||||
|
|
||||||
|
const [discovered] = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(discovered?.entrypoints.ui).toBeUndefined();
|
||||||
|
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "ui")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a source TypeScript UI entrypoint where compiled output is required", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "signage", {
|
||||||
|
manifest: manifestWithUi("signage", "./src/ui/index.ts"),
|
||||||
|
extraFiles: { "src/ui/index.ts": "export default {};" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
||||||
|
expect(error.issues.map((issue) => issue.path)).toContain("entrypoints.ui");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing UI artifact rather than silently ignoring the declaration", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "video", {
|
||||||
|
manifest: manifestWithUi("video", "./dist/ui/index.js"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a UI entrypoint that resolves to a directory instead of a file", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "livestream", {
|
||||||
|
manifest: manifestWithUi("livestream", "./dist/ui.js"),
|
||||||
|
extraFiles: { "dist/ui.js/placeholder.txt": "not a file entrypoint" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a UI entrypoint linked outside its own module package", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
const externalDirectory = await createModulesDirectory();
|
||||||
|
const externalArtifact = path.join(externalDirectory, "foreign-ui.js");
|
||||||
|
await writeFile(externalArtifact, "export default {};");
|
||||||
|
await createModule(modulesDirectory, "calendar", {
|
||||||
|
manifest: manifestWithUi("calendar", "./dist/ui/index.js"),
|
||||||
|
});
|
||||||
|
await mkdir(path.join(modulesDirectory, "calendar/dist/ui"), { recursive: true });
|
||||||
|
await symlink(externalArtifact, path.join(modulesDirectory, "calendar/dist/ui/index.js"));
|
||||||
|
|
||||||
|
const error = await discoveryError(modulesDirectory);
|
||||||
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never imports a UI artifact merely to discover it", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createModule(modulesDirectory, "announcements", {
|
||||||
|
manifest: manifestWithUi("announcements", "./dist/ui/index.js"),
|
||||||
|
// Discovery is inert: a throwing artifact must not be evaluated. If discovery ever
|
||||||
|
// imported a UI entrypoint, this would surface as a rejected promise rather than a
|
||||||
|
// successfully discovered module.
|
||||||
|
extraFiles: {
|
||||||
|
"dist/ui/index.js": "throw new Error('a UI entrypoint must never be evaluated');",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovered = await discoverModules({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(discovered).toHaveLength(1);
|
||||||
|
expect(discovered[0]?.entrypoints.ui).toContain("dist/ui/index.js");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface PermissionInput {
|
|||||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function manifestFor(
|
|||||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ would couple business behavior to a particular presentation framework.
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
|
> **Partial correction — see [ADR 0021](0021-modules-as-hostable-products.md).**
|
||||||
|
> The rule below that a module exposes "data and behavior only" and that "every renderer and UI
|
||||||
|
> concern belongs to a surface" was superseded by ADR 0021: a module may own its own UI and ship it
|
||||||
|
> as part of an independently versioned product, hostable by Ekklesia and, later, by a standalone
|
||||||
|
> host. The four ownership layers themselves, Core's feature-agnostic role, the prohibition on Core
|
||||||
|
> importing a module, and every other boundary in this ADR remain accepted and unchanged — Core
|
||||||
|
> still owns the containing application shell and all platform policy. This section is left as
|
||||||
|
> originally accepted, as the historical record.
|
||||||
|
|
||||||
|
|
||||||
Adopt four explicit ownership layers: Core orchestration, backend Modules, presentation Surfaces,
|
Adopt four explicit ownership layers: Core orchestration, backend Modules, presentation Surfaces,
|
||||||
and declarative Surface Builder configuration. Begin as a modular monolith. Modules expose data
|
and declarative Surface Builder configuration. Begin as a modular monolith. Modules expose data
|
||||||
and behavior only; every renderer and UI concern belongs to a surface.
|
and behavior only; every renderer and UI concern belongs to a surface.
|
||||||
|
|||||||
+7
@@ -50,6 +50,13 @@ server entrypoint, job, or lifecycle hook to discover its permissions; only the
|
|||||||
|
|
||||||
### Manifest version 3 replaces version 2's bare permission strings
|
### Manifest version 3 replaces version 2's bare permission strings
|
||||||
|
|
||||||
|
> **Partial correction — see [ADR 0021](0021-modules-as-hostable-products.md).**
|
||||||
|
> `manifestVersion: 3` was superseded by ADR 0021's `manifestVersion: 4`, which adds the optional
|
||||||
|
> `entrypoints.ui` field. The permission-declaration shape this section establishes, the reserved
|
||||||
|
> `core.*` namespace, the composed platform registry, and the inert-manifest discovery model are
|
||||||
|
> carried forward into version 4 unchanged; only the accepted version literal moved. The
|
||||||
|
> version-rejection rule described below applies identically to version 3 under version 4.
|
||||||
|
|
||||||
Representing a permission as a complete declaration instead of a bare identifier string is an
|
Representing a permission as a complete declaration instead of a bare identifier string is an
|
||||||
incompatible manifest shape change, so the canonical manifest moves to `manifestVersion: 3`
|
incompatible manifest shape change, so the canonical manifest moves to `manifestVersion: 3`
|
||||||
(`z.literal(3)`). Version 2 — and any other version — fails validation outright; there is no
|
(`z.literal(3)`). Version 2 — and any other version — fails validation outright; there is no
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# 0021: Modules as independently hostable products
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-21
|
||||||
|
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||||
|
[0014](0014-module-permission-declarations-and-platform-registry.md)
|
||||||
|
- Supersedes (partial): [0001](0001-four-layer-platform-boundary.md) — only its rule that a module
|
||||||
|
never owns UI and that `surfaces/*` owns all presentation. The four ownership layers, Core's
|
||||||
|
feature-agnostic role, and the prohibition on Core importing a module remain accepted and
|
||||||
|
unchanged.
|
||||||
|
- Supersedes (partial): [0014](0014-module-permission-declarations-and-platform-registry.md) — only
|
||||||
|
its `manifestVersion: 3` literal. Permission declarations, the composed platform registry, the
|
||||||
|
reserved `core.*` namespace, and the inert-manifest discovery model remain accepted and unchanged.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Every module contract this repository has accepted so far — static manifests (ADR 0005/0006),
|
||||||
|
module-owned schemas and migrations (ADR 0007/0008/0009), permission declarations (ADR 0014),
|
||||||
|
framework-neutral HTTP routes (ADR 0015), and typed database access (ADR 0016) — describes a
|
||||||
|
*backend capability*. ADR 0001 placed all presentation in `surfaces/*` and forbade a module from
|
||||||
|
owning any UI.
|
||||||
|
|
||||||
|
That split has a cost the platform can no longer absorb. A module like Announcements owns its
|
||||||
|
domain model, its permissions, its storage, its routes, and its migrations, but cannot own the one
|
||||||
|
screen that makes it useful. Under ADR 0001, an announcement editor must be written independently
|
||||||
|
by whoever builds a surface, against a module the surface author does not own and cannot see the
|
||||||
|
internals of. Every module then needs a second, separately maintained implementation of its own
|
||||||
|
product experience, and the module's author — the person who actually knows the domain — is
|
||||||
|
structurally prevented from shipping it.
|
||||||
|
|
||||||
|
The same pressure appears from the other direction. A church that wants only announcements should
|
||||||
|
eventually be able to run that module as a standalone application. If Ekklesia-hosted and
|
||||||
|
standalone experiences are two codebases, they diverge immediately: two editors, two validation
|
||||||
|
rules, two permission interpretations, two release cadences, one domain.
|
||||||
|
|
||||||
|
Neither problem is solved by more surface tooling. Both are the same missing idea: a module is a
|
||||||
|
*product*, not just a backend capability, and a product includes its own presentation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Modules are independently versioned, first-class products
|
||||||
|
|
||||||
|
A module owns its domain behavior, data, migrations, permissions, capabilities, HTTP routes, and —
|
||||||
|
new in this ADR — its own UI. It remains an independently versioned package with its own semantic
|
||||||
|
version, its own capability versions, and its own compatibility ranges, exactly as ADR 0006
|
||||||
|
established. Nothing about becoming a product changes how a module is versioned, discovered,
|
||||||
|
ordered, or validated.
|
||||||
|
|
||||||
|
### Modules may own UI; Core owns the host shell and platform policy
|
||||||
|
|
||||||
|
A module may ship its own management screen, its feature-specific presentation behavior, and
|
||||||
|
reusable UI belonging to its feature. It does **not** own the application that contains it.
|
||||||
|
|
||||||
|
When a module's UI runs inside Ekklesia, Core and the host application remain solely responsible
|
||||||
|
for authentication and session establishment, authorization enforcement, organization/platform
|
||||||
|
context, the containing application shell, top-level routing and mount boundaries, module
|
||||||
|
discovery, the platform navigation container, the global theme and design-token contract, platform
|
||||||
|
error boundaries, cross-module coordination contracts, trusted-origin/CORS and other security
|
||||||
|
policy, and every platform service exposed through a deliberate contract. A module UI entrypoint
|
||||||
|
consumes those; it never redefines, replaces, or bypasses them. This mirrors the boundary the
|
||||||
|
backend already has: ADR 0015 lets a module mount routes, but the platform still owns method
|
||||||
|
vocabulary, CORS preflight, body ceilings, and permission enforcement.
|
||||||
|
|
||||||
|
### The module hierarchy is flat
|
||||||
|
|
||||||
|
There is no architectural parent/child relationship between modules, and none is being introduced.
|
||||||
|
`video`, `livestream`, `announcements`, `calendar`, `roster`, and `signage` are peers. A module that
|
||||||
|
ships UI gains no containment relationship over any other module.
|
||||||
|
|
||||||
|
The only relationships a module may express are the two that already exist and remain fully
|
||||||
|
supported: a genuine module dependency and a genuine capability dependency, both version-ranged and
|
||||||
|
both resolved by discovery into a dependency-ordered graph (ADR 0006). Those express *requirement*,
|
||||||
|
not *ownership*, and they are the correct mechanism when one module truly cannot function without
|
||||||
|
another. Conceptual relatedness is not a dependency and must never be modelled as one.
|
||||||
|
|
||||||
|
### Dashboard grouping is presentation configuration, not module architecture
|
||||||
|
|
||||||
|
A Dashboard may eventually present independent modules inside folders or groups — `Media` holding
|
||||||
|
Video and Livestream, or `Sunday Production` holding Livestream, Signage, and Video. Those groupings
|
||||||
|
belong to Dashboard/surface configuration. They are not module ownership, not dependency, and not
|
||||||
|
part of the module contract.
|
||||||
|
|
||||||
|
A module may eventually offer presentation metadata such as a *suggested* default grouping, but it
|
||||||
|
must never own the folder it appears in, and correctness must never depend on that hierarchy. An
|
||||||
|
administrator must be able to reorganize every module without any module changing. Accordingly, the
|
||||||
|
manifest deliberately declares no group, folder, parent, menu, navigation, icon, or ordering field,
|
||||||
|
and none may be added as a hierarchy mechanism.
|
||||||
|
|
||||||
|
### One module implementation, many hosts
|
||||||
|
|
||||||
|
Standalone support is explicitly *not* a second application. The intended shape is one module
|
||||||
|
implementation consumed by thin host-specific composition:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Announcements module
|
||||||
|
/ \
|
||||||
|
Ekklesia host Standalone host
|
||||||
|
```
|
||||||
|
|
||||||
|
Domain behavior, APIs, schema, migrations, permissions, UI, and feature logic are the module's and
|
||||||
|
are reusable by both. Host-specific composition stays thin. This ADR does not implement a standalone
|
||||||
|
host; it forbids designing the public contract in a way that makes one impossible. Concretely: a
|
||||||
|
module's UI entrypoint must not be fundamentally dependent on a private Ekklesia dashboard
|
||||||
|
application or on Core internals, and the contract must not be named after, or shaped around, one
|
||||||
|
particular host.
|
||||||
|
|
||||||
|
### `entrypoints.ui` is the smallest primitive that establishes this
|
||||||
|
|
||||||
|
The manifest gains exactly one new optional field: `entrypoints.ui`, a package-relative path to a
|
||||||
|
compiled artifact, validated identically to every other entrypoint. That is the whole new contract.
|
||||||
|
|
||||||
|
The name is deliberate. It is not `dashboard`, because a module's UI must be hostable by Ekklesia's
|
||||||
|
shell *and* by a standalone host, so the contract cannot be named after one of them. It is not
|
||||||
|
`surface`, because `surfaces/*` is a separate ownership layer (ADR 0001) and reusing that word for a
|
||||||
|
module-owned artifact would conflate two different things. `ui` is host-neutral, platform-neutral,
|
||||||
|
and already this repository's own vocabulary for the concept.
|
||||||
|
|
||||||
|
No widget, route, icon, folder, menu, layout, page-builder, or standalone-metadata field is added.
|
||||||
|
Those contracts, if they are ever needed, evolve from this primitive under a later manifest version.
|
||||||
|
|
||||||
|
### The UI entrypoint is a compiled artifact, statically validated, and never executed by discovery
|
||||||
|
|
||||||
|
`entrypoints.ui` obeys exactly the rules ADR 0006 established for `server` and `schema`: it must be
|
||||||
|
package-relative, must stay inside the module package (checked before and after symlink resolution),
|
||||||
|
must resolve to a real file, and must be compiled ESM JavaScript ending in `.js` or `.mjs`. Source
|
||||||
|
TypeScript is rejected at discovery rather than assumed to be compiled later.
|
||||||
|
|
||||||
|
Discovery remains completely inert. Core parses the manifest as static JSON, resolves the UI path,
|
||||||
|
and stores it. It does not import, evaluate, render, mount, or otherwise execute a UI entrypoint —
|
||||||
|
and nothing in this repository does yet. This follows the precedent already set by `entrypoints.jobs`,
|
||||||
|
which has been declared and resolved since ADR 0006 with no runtime consumer.
|
||||||
|
|
||||||
|
### Manifest version 4 replaces version 3
|
||||||
|
|
||||||
|
Version 3's `entrypoints` object is `.strict()` and rejects `ui` outright, so accepting a UI
|
||||||
|
entrypoint is an incompatible shape change. The canonical manifest moves to `manifestVersion: 4`
|
||||||
|
(`z.literal(MODULE_MANIFEST_VERSION)`), and version 3 — like every other version — fails validation.
|
||||||
|
There is no compatibility adapter, no automatic conversion, and no dual acceptance, exactly as
|
||||||
|
version 1 was rejected by version 2 (ADR 0006) and version 2 by version 3 (ADR 0014). One manifest
|
||||||
|
document can therefore never be interpreted as two different versions.
|
||||||
|
|
||||||
|
Backward compatibility is deliberate rather than automatic: because every field version 4 adds is
|
||||||
|
optional, upgrading a backend-only version 3 manifest is a one-character edit and changes nothing
|
||||||
|
else. A backend-only module remains completely valid and completely supported — declaring UI is a
|
||||||
|
choice, never a requirement.
|
||||||
|
|
||||||
|
### Restart-based discovery remains correct
|
||||||
|
|
||||||
|
Installed module inventory is composed at startup and changes take effect on the next restart. This
|
||||||
|
ADR adds no hot installation, no runtime unloading, no filesystem watching, and no dynamic mutation
|
||||||
|
of the running registry. A future module-management UI does not imply hot-loading, and module UI
|
||||||
|
does not change the reasoning ADR 0014 recorded for the static startup snapshot.
|
||||||
|
|
||||||
|
### Migrations remain explicit
|
||||||
|
|
||||||
|
Module installation and API startup never apply migrations. Modules continue to own their Drizzle
|
||||||
|
schema and migration history, applied through Core's existing validated, operator-invoked workflow
|
||||||
|
under an advisory lock and one transaction (ADR 0008/0009/0020). Destructive-migration approval,
|
||||||
|
checksums, planning, locking, and transactional behavior are untouched by this ADR.
|
||||||
|
|
||||||
|
### The trusted compiled-module security model is unchanged
|
||||||
|
|
||||||
|
Installed modules remain trusted compiled code running inside the API process and sharing its
|
||||||
|
PostgreSQL authority. Manifest validation, typed facades, route restrictions, table naming, and
|
||||||
|
frozen contexts remain correctness and coordination boundaries — not a sandbox. **Adding a UI
|
||||||
|
entrypoint grants no isolation and implies none.** A module that ships UI is exactly as trusted as
|
||||||
|
one that does not, and an operator installing either is deploying trusted code. See
|
||||||
|
[Security model and deployment trust boundaries](../../development/security.md).
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Surfaces own all UI (the status quo, ADR 0001).** Rejected because it forces every module's
|
||||||
|
product experience to be re-implemented by someone who does not own the domain, and guarantees
|
||||||
|
that a module and its own screen drift apart. It is the specific constraint this ADR exists to
|
||||||
|
remove.
|
||||||
|
- **Parent modules own child modules** (`media` containing `video` and `livestream`). Rejected
|
||||||
|
because it encodes one organization's conceptual taxonomy as an architectural dependency. It
|
||||||
|
makes an administrator's presentation preference a code change, forces artificial dependencies
|
||||||
|
between modules that do not actually require one another, prevents a "child" from being installed
|
||||||
|
or versioned alone, and conflates grouping with dependency resolution. Flat peers plus genuine
|
||||||
|
dependencies express everything real, and Dashboard configuration expresses the rest.
|
||||||
|
- **Separate standalone and Ekklesia implementations.** Rejected because two codebases for one
|
||||||
|
domain diverge immediately in validation, permissions, and behavior, and double the maintenance
|
||||||
|
and audit surface for every module forever. One implementation with thin host composition is the
|
||||||
|
entire point of making a module a product.
|
||||||
|
- **Hot-load modules immediately.** Rejected for this phase. It introduces concurrent-mutation and
|
||||||
|
partial-update hazards across the registry, the permission registry, the route table, and now a
|
||||||
|
UI surface, none of which this phase needs to solve. ESM evaluation cannot be rolled back or
|
||||||
|
unloaded (ADR 0006), so hot replacement is a substantially harder problem than it appears. A
|
||||||
|
restart remains sufficient for the current deployment model.
|
||||||
|
- **Automatic migrations on install or startup.** Rejected, and rejected consistently with every
|
||||||
|
prior ADR: a module gaining UI does not make schema change safe to perform implicitly. Automatic
|
||||||
|
application would defeat destructive approval, remove the operator's review step, and make startup
|
||||||
|
a mutating database operation.
|
||||||
|
- **Extending version 3 in place with a new optional field.** Rejected because version 3's
|
||||||
|
`entrypoints` is strict: silently widening it would change what an existing declared version means
|
||||||
|
and make one document ambiguously readable as two versions. An explicit version bump keeps
|
||||||
|
rejection behavior clear and validation deterministic.
|
||||||
|
- **A richer first UI contract** (mount routes, widgets, icons, navigation entries, grouping hints,
|
||||||
|
standalone metadata). Rejected as premature. None of it can be designed honestly before a host
|
||||||
|
exists to consume it, and every field added now would be a guess frozen into a strict public
|
||||||
|
contract.
|
||||||
|
- **Naming the entrypoint `dashboard`.** Rejected because it names one prospective host and would
|
||||||
|
bias the contract against standalone reuse — the exact coupling this ADR is trying to avoid.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A module author can now express the complete product they own, and the platform has one contract for
|
||||||
|
discovering that a module offers presentation at all. Backend-only modules are unaffected beyond a
|
||||||
|
one-character manifest version bump. The dependency graph stays flat and meaningful, and Dashboard
|
||||||
|
organization stays an administrator's decision rather than an architectural one.
|
||||||
|
|
||||||
|
The cost is a declared contract with no consumer: `entrypoints.ui` can be validated but not yet
|
||||||
|
hosted, exactly like `entrypoints.jobs`. That is deliberate — the static contract is established
|
||||||
|
first so the runtime design in a later pass has a fixed, tested boundary to build against — but
|
||||||
|
until that pass lands, declaring `ui` accomplishes nothing observable at runtime.
|
||||||
|
|
||||||
|
This ADR also creates a real, tracked tension with an enforced repository rule. `pnpm boundaries`
|
||||||
|
(`scripts/check-boundaries.ts`, rule `modules-do-not-own-ui`) rejects UI *source* extensions
|
||||||
|
(`.tsx`, `.vue`, `.css`, `.svg`, and others) and UI *framework dependencies* inside a module
|
||||||
|
package. That rule is intentionally left enforced by this ADR: whether and how to relax it depends
|
||||||
|
entirely on the UI authoring contract a later pass chooses, and weakening a working guard before
|
||||||
|
that decision exists would trade a real protection for nothing. A module may therefore declare
|
||||||
|
`entrypoints.ui` today only for a framework-neutral compiled artifact, and choosing the module UI
|
||||||
|
authoring model — including exactly which parts of that deny list are relaxed, and what replaces
|
||||||
|
them — is the first question the next pass must answer.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
None of the following is implemented, designed, or promised by this ADR: the Dashboard application
|
||||||
|
shell; a UI component library or design-token implementation; any React/Vue/Svelte or other
|
||||||
|
framework choice; a module UI runtime, renderer, loader, or mount mechanism; navigation registration;
|
||||||
|
Dashboard folders, groups, or grouping metadata; a standalone application host; a module-management
|
||||||
|
UI; a module installer or marketplace; hot-loading or runtime unloading; automatic migration
|
||||||
|
application; and any real feature module. The `entrypoints.ui` field is a static declaration only.
|
||||||
@@ -29,3 +29,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy](0018-identity-session-resolution-and-database-transport.md) — Accepted
|
- [0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy](0018-identity-session-resolution-and-database-transport.md) — Accepted
|
||||||
- [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted
|
- [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted
|
||||||
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
|
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
|
||||||
|
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ Ekklesia is a long-lived platform from which churches can compose many applicati
|
|||||||
experiences. It begins as a modular monolith so contracts can stabilize before distribution is
|
experiences. It begins as a modular monolith so contracts can stabilize before distribution is
|
||||||
introduced.
|
introduced.
|
||||||
|
|
||||||
> **Trust boundary.** Installed modules are trusted compiled backend code, not sandboxed
|
> **Trust boundary.** Installed modules are trusted compiled code, not sandboxed plugins: a module
|
||||||
> plugins: a module runs arbitrary JavaScript in the API process and shares its PostgreSQL
|
> runs arbitrary JavaScript in the API process and shares its PostgreSQL role. The layering
|
||||||
> role. The layering described below is a correctness and ownership boundary, not hostile-code
|
> described below is a correctness and ownership boundary, not hostile-code isolation. A module that
|
||||||
> isolation. See [Security model and deployment trust boundaries](../development/security.md).
|
> ships its own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) is exactly as
|
||||||
|
> trusted as one that does not. See
|
||||||
|
> [Security model and deployment trust boundaries](../development/security.md).
|
||||||
|
|
||||||
## Dependency model
|
## Dependency model
|
||||||
|
|
||||||
@@ -27,16 +29,26 @@ The four ownership layers are:
|
|||||||
|
|
||||||
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
|
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
|
||||||
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
|
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
|
||||||
2. **Modules** own backend domain behavior, data schemas, resources, actions, routes, events,
|
2. **Modules** own domain behavior, data schemas, resources, actions, routes, events, permissions,
|
||||||
permissions, and jobs.
|
jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI. A module
|
||||||
3. **Surfaces** own every presentation concern: components, renderers, layout, navigation,
|
is an independently versioned product, not only a backend capability. It does not own the
|
||||||
accessibility, interaction, branding, and client state.
|
containing application shell or any platform policy.
|
||||||
|
3. **Surfaces** own the presentation concerns a module does not: the containing application shell,
|
||||||
|
layout, navigation, accessibility, interaction, branding, and client state — including the shell
|
||||||
|
that hosts a module's own UI.
|
||||||
4. **Surface Builder** stores validated declarative composition. It selects capabilities and
|
4. **Surface Builder** stores validated declarative composition. It selects capabilities and
|
||||||
surface-owned renderers but contains neither executable code nor module business logic.
|
surface-owned renderers but contains neither executable code nor module business logic.
|
||||||
|
|
||||||
Modules do not own UI. For example, Calendar data, actions, and permissions will live in
|
A module owns its own product experience. Calendar data, actions, permissions, and its own
|
||||||
`modules/calendar`; Calendar renderers for a dashboard or public website will live in those
|
management UI live in `modules/calendar`; the dashboard or public-website shell that hosts it —
|
||||||
surfaces.
|
navigation, theme, layout, and platform chrome — lives in a surface. The module supplies the screen
|
||||||
|
for its feature; the surface supplies the application around it.
|
||||||
|
|
||||||
|
Modules are flat peers. `video`, `livestream`, `announcements`, and `calendar` are independent
|
||||||
|
first-class modules with no parent/child relationship. A Dashboard may group them into folders such
|
||||||
|
as `Media` or `Sunday Production`, but those groups are presentation configuration an administrator
|
||||||
|
can reorganize, never module ownership and never a dependency. Genuine module and capability
|
||||||
|
dependencies remain the only way one module may require another.
|
||||||
|
|
||||||
## Stable seams
|
## Stable seams
|
||||||
|
|
||||||
@@ -151,7 +163,21 @@ blocks Phase 6. Read
|
|||||||
does and does not protect against, and the repository root
|
does and does not protect against, and the repository root
|
||||||
[README](../../README.md#current-status) for the current status of every phase.
|
[README](../../README.md#current-status) for the current status of every phase.
|
||||||
|
|
||||||
The module platform's backend foundations are now in place and Phase 6 Surface work is unblocked.
|
**Phase 6A establishes the module product contract.**
|
||||||
|
[ADR 0021](decisions/0021-modules-as-hostable-products.md) replaces the backend-only module model:
|
||||||
|
a module is an independently versioned product that may own its own UI, hostable inside Ekklesia and
|
||||||
|
— later, from the same implementation — as a standalone application. Core keeps the host shell and
|
||||||
|
every platform policy, the module graph stays flat, and Dashboard grouping stays presentation
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
What this pass actually implements is the **static contract only**: `manifestVersion: 4` adds one
|
||||||
|
optional `entrypoints.ui` field, validated during discovery exactly like the server and schema
|
||||||
|
entrypoints — package-relative, inside the package, compiled ESM. Discovery remains inert; nothing
|
||||||
|
imports, mounts, or renders that entrypoint. The Dashboard shell, the UI runtime, the standalone
|
||||||
|
host, module UI authoring (including the still-enforced `modules-do-not-own-ui` boundary rule), and
|
||||||
|
every feature module remain future work. Restart-based discovery, explicit operator-invoked
|
||||||
|
migrations, and the trusted compiled-module model are all unchanged.
|
||||||
|
|
||||||
Target architecture not yet implemented includes production approval-provider
|
Target architecture not yet implemented includes production approval-provider
|
||||||
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
|
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
|
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
||||||
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
||||||
| `modules/*` | Backend domain capability and data | Module SDK, public contracts, and external backend libraries |
|
| `modules/*` | Domain capability, data, and (per ADR 0021) the module's own UI | Module SDK, public contracts, and external backend libraries |
|
||||||
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
|
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
|
||||||
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
||||||
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
||||||
@@ -19,7 +19,12 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
|
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
|
||||||
- A module cannot contain presentation source, including JSX/TSX, single-file components,
|
- A module cannot contain presentation source, including JSX/TSX, single-file components,
|
||||||
Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI
|
Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI
|
||||||
frameworks or shared UI-role packages. Modules expose data and behavior only.
|
frameworks or shared UI-role packages. **This rule is still enforced, but it is no longer the
|
||||||
|
architectural direction.** [ADR 0021](decisions/0021-modules-as-hostable-products.md) establishes
|
||||||
|
that a module may own its own UI and declare a compiled `entrypoints.ui` artifact. The deny list
|
||||||
|
above stays in force until a later Phase 6 pass chooses the module UI authoring model and decides
|
||||||
|
what replaces it; until then a module UI must be a framework-neutral compiled artifact. Core still
|
||||||
|
owns the containing application shell and all platform policy.
|
||||||
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
||||||
- Separate surfaces do not import each other's internals.
|
- Separate surfaces do not import each other's internals.
|
||||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||||
@@ -39,7 +44,8 @@ The boundary test suite includes intentionally invalid fixture graphs. This prov
|
|||||||
rejects every currently machine-checkable repository rule even before production packages fill
|
rejects every currently machine-checkable repository rule even before production packages fill
|
||||||
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
||||||
common UI-framework dependencies inside modules. This deny list is defense in depth; package
|
common UI-framework dependencies inside modules. This deny list is defense in depth; package
|
||||||
review remains required when adopting a new frontend technology.
|
review remains required when adopting a new frontend technology — and revisiting the guard itself is
|
||||||
|
an explicit prerequisite of the module UI authoring pass (ADR 0021).
|
||||||
|
|
||||||
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
|
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
|
||||||
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
|
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ below to build on without reconstructing Core's own list:
|
|||||||
|
|
||||||
## Module permission composition
|
## Module permission composition
|
||||||
|
|
||||||
A module manifest's `permissions` array (`manifestVersion: 3`,
|
A module manifest's `permissions` array (`manifestVersion: 4`,
|
||||||
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||||||
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||||||
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||||||
@@ -593,7 +593,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many
|
|||||||
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
||||||
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
||||||
rejection, malformed permission id rejection.
|
rejection, malformed permission id rejection.
|
||||||
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 3` acceptance and version
|
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version
|
||||||
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
||||||
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
||||||
path, and default-empty-list isolation across separate parses.
|
path, and default-empty-list isolation across separate parses.
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ cooperative. None of them constrains a module that is not:
|
|||||||
schema-permission, table-ACL, row-level-security, tenant, or resource sandbox between a module and
|
schema-permission, table-ACL, row-level-security, tenant, or resource sandbox between a module and
|
||||||
Core.** A module is not isolated from Core, from other modules, or from the host.
|
Core.** A module is not isolated from Core, from other modules, or from the host.
|
||||||
|
|
||||||
|
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) lets a module declare its
|
||||||
|
own UI entrypoint. **That changes nothing on this page.** A module that ships UI is exactly as
|
||||||
|
trusted as one that does not: its server entrypoint still runs in the API process with the authority
|
||||||
|
described above, and declaring `entrypoints.ui` neither adds nor implies any isolation. No host
|
||||||
|
loads a UI entrypoint yet; when one does, the trust model for whatever it executes must be decided
|
||||||
|
explicitly and recorded — it does not inherit a boundary from this section, because none exists.
|
||||||
|
|
||||||
## What this platform must not claim
|
## What this platform must not claim
|
||||||
|
|
||||||
Ekklesia today has **none** of the following, and no document, README, or marketing surface may
|
Ekklesia today has **none** of the following, and no document, README, or marketing surface may
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Module development
|
# Module development
|
||||||
|
|
||||||
An Ekklesia module is a backend capability package. It owns business behavior and data declarations;
|
An Ekklesia module is an independently versioned, first-class product. It owns its domain behavior,
|
||||||
it never owns components, styles, layouts, renderers, navigation, or other UI.
|
data declarations, permissions, capabilities, HTTP routes, migrations — and, as of
|
||||||
|
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md), its own UI.
|
||||||
|
|
||||||
|
It does **not** own the application that contains it. Authentication, authorization enforcement,
|
||||||
|
platform context, the application shell, top-level routing, navigation, the global theme and
|
||||||
|
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
|
||||||
|
application's. A module consumes those; it never redefines or bypasses them.
|
||||||
|
|
||||||
|
> **What exists today:** a module may *declare* a UI entrypoint in its manifest, and Core validates
|
||||||
|
> it. **Nothing loads, mounts, or renders it yet** — the host shell, the UI runtime, and the
|
||||||
|
> standalone host are all later Phase 6 work. Writing a module UI is not yet possible end to end;
|
||||||
|
> see [Declaring a UI entrypoint](#declaring-a-ui-entrypoint) for exactly what is and is not
|
||||||
|
> supported.
|
||||||
|
|
||||||
> **Before you publish or install a module:** modules are trusted compiled backend code. Your
|
> **Before you publish or install a module:** modules are trusted compiled backend code. Your
|
||||||
> module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
|
> module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
|
||||||
@@ -54,7 +66,7 @@ without executing module code:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"manifestVersion": 3,
|
"manifestVersion": 4,
|
||||||
"id": "calendar",
|
"id": "calendar",
|
||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -111,9 +123,12 @@ has its own exact semantic version because its public contract can evolve indepe
|
|||||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||||
may be absent; if it is installed, its capability version must be compatible.
|
may be absent; if it is installed, its capability version must be compatible.
|
||||||
|
|
||||||
`manifestVersion: 3` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
`manifestVersion: 4` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||||
version 2 manifest, and any other version, fails validation outright rather than being silently
|
version 3 manifest, and any other version, fails validation outright rather than being silently
|
||||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||||
|
Version 4 adds one optional field, `entrypoints.ui`, so upgrading a backend-only version 3 manifest
|
||||||
|
is a one-character edit and changes nothing else
|
||||||
|
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md)).
|
||||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||||
@@ -138,16 +153,92 @@ module's own HTTP routes, and creating relationship-derived grants automatically
|
|||||||
own business logic, are both still later work; this only makes a declared permission *usable*
|
own business logic, are both still later work; this only makes a declared permission *usable*
|
||||||
through Core's existing, generic surface.
|
through Core's existing, generic surface.
|
||||||
|
|
||||||
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
|
Server and optional schema and UI entrypoints must be compiled ESM `.js` or `.mjs` files. The
|
||||||
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
|
optional migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the
|
||||||
and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
package, and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
||||||
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`. Unknown
|
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`; modules with
|
||||||
manifest properties are rejected, including presentation fields such as components or renderers.
|
no UI omit `entrypoints.ui`.
|
||||||
|
|
||||||
|
Unknown manifest properties are still rejected. `ui` is the only entrypoint version 4 adds — a
|
||||||
|
`dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest
|
||||||
|
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
|
||||||
|
organization is presentation configuration owned by the Dashboard, never module architecture.
|
||||||
|
|
||||||
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
|
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
|
||||||
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
||||||
module code must not configure SeaweedFS or another S3 client directly.
|
module code must not configure SeaweedFS or another S3 client directly.
|
||||||
|
|
||||||
|
## Declaring a UI entrypoint
|
||||||
|
|
||||||
|
A module may declare one optional presentation entrypoint alongside its server, schema, and
|
||||||
|
migration entrypoints:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entrypoints": {
|
||||||
|
"server": "./dist/server/index.js",
|
||||||
|
"schema": "./dist/schema/index.js",
|
||||||
|
"migrations": "./drizzle",
|
||||||
|
"ui": "./dist/ui/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ui` obeys exactly the rules `server` and `schema` already obey. It must be package-relative, stay
|
||||||
|
inside the module package (checked before *and* after symlink resolution), resolve to a real file,
|
||||||
|
and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts` path is rejected at
|
||||||
|
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
|
||||||
|
any other.
|
||||||
|
|
||||||
|
### What this does and does not do today
|
||||||
|
|
||||||
|
Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on
|
||||||
|
the discovered module. That is all. **Nothing in this repository imports, evaluates, mounts, or
|
||||||
|
renders a UI entrypoint** — a UI artifact that throws on evaluation still discovers successfully,
|
||||||
|
because it is never evaluated. The field follows the same established pattern as `entrypoints.jobs`,
|
||||||
|
which has been declared and validated since ADR 0006 with no runtime consumer.
|
||||||
|
|
||||||
|
Declaring `ui` therefore has no observable runtime effect yet. It exists so the host shell, the UI
|
||||||
|
runtime, and a future standalone host have a fixed, tested contract to build against.
|
||||||
|
|
||||||
|
### The authoring constraint you will hit
|
||||||
|
|
||||||
|
`pnpm boundaries` enforces `modules-do-not-own-ui`, which rejects UI *source* extensions (`.tsx`,
|
||||||
|
`.vue`, `.svelte`, `.css`, `.svg`, and others) and UI *framework dependencies* (React, Vue, Svelte,
|
||||||
|
Tailwind, and others) inside a module package. **That rule is still enforced.** ADR 0021 changed the
|
||||||
|
architectural direction — a module may own UI — but the authoring model for module UI, including
|
||||||
|
which parts of that deny list are relaxed and what replaces them, is a later Phase 6 decision that
|
||||||
|
has not been made.
|
||||||
|
|
||||||
|
In practice this means a module can declare `entrypoints.ui` today only for a framework-neutral
|
||||||
|
compiled artifact authored in plain `.ts`. Do not add a UI framework dependency to a module package
|
||||||
|
expecting it to pass; it will not, and that is deliberate rather than an oversight.
|
||||||
|
|
||||||
|
### What a module UI will never own
|
||||||
|
|
||||||
|
Whatever authoring model is chosen, a module UI consumes platform policy and never redefines it.
|
||||||
|
Authentication and session establishment, authorization enforcement, organization/platform context,
|
||||||
|
the containing application shell, top-level routing and mount boundaries, the platform navigation
|
||||||
|
container, the global theme and design-token contract, platform error boundaries, and
|
||||||
|
trusted-origin/CORS policy belong to Core and the host application. This is the same boundary module
|
||||||
|
HTTP routes already live inside: a module mounts routes, but the platform still owns the method
|
||||||
|
vocabulary, CORS preflight, body ceilings, and permission enforcement.
|
||||||
|
|
||||||
|
A module UI also grants no isolation. A module that ships UI is exactly as trusted as one that does
|
||||||
|
not — see [Security model and deployment trust boundaries](../development/security.md).
|
||||||
|
|
||||||
|
### Modules stay flat
|
||||||
|
|
||||||
|
Shipping UI creates no parent/child relationship between modules. `video`, `livestream`,
|
||||||
|
`announcements`, `calendar`, `roster`, and `signage` are peers. The only relationships a module may
|
||||||
|
express are a genuine module dependency and a genuine capability dependency — both version-ranged,
|
||||||
|
both meaning *requirement*, never *ownership*.
|
||||||
|
|
||||||
|
A Dashboard may later group modules into folders (`Media` holding Video and Livestream, or
|
||||||
|
`Sunday Production` holding Livestream, Signage, and Video). Those groups are Dashboard
|
||||||
|
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
||||||
|
declared in your manifest, and your module must never require one to work correctly.
|
||||||
|
|
||||||
## Runtime server definition
|
## Runtime server definition
|
||||||
|
|
||||||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
||||||
|
|||||||
@@ -59,12 +59,26 @@ const moduleStorageSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.default(() => ({ buckets: [] }));
|
.default(() => ({ buckets: [] }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compiled artifacts a module publishes for the platform to resolve. Every entry is a
|
||||||
|
* package-relative path to a build output, never source: Core resolves and validates these
|
||||||
|
* statically during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
|
||||||
|
*
|
||||||
|
* `ui` is a module's optional, host-neutral presentation entrypoint (ADR 0021). It is declared and
|
||||||
|
* statically validated here, exactly like `jobs` already is, but **no runtime consumer exists yet**
|
||||||
|
* — nothing in this repository imports, evaluates, mounts, or renders it. Its name is deliberately
|
||||||
|
* not `dashboard`: a module's UI must be hostable by Ekklesia's own application shell *and* by a
|
||||||
|
* future standalone host, so the contract cannot be named after one of them. It is deliberately not
|
||||||
|
* `surface` either — `surfaces/*` is a separate ownership layer (ADR 0001), and reusing that word
|
||||||
|
* for a module-owned artifact would conflate two different things.
|
||||||
|
*/
|
||||||
const entrypointsSchema = z
|
const entrypointsSchema = z
|
||||||
.object({
|
.object({
|
||||||
server: packageRelativePathSchema,
|
server: packageRelativePathSchema,
|
||||||
schema: packageRelativePathSchema.optional(),
|
schema: packageRelativePathSchema.optional(),
|
||||||
migrations: packageRelativePathSchema.optional(),
|
migrations: packageRelativePathSchema.optional(),
|
||||||
jobs: packageRelativePathSchema.optional(),
|
jobs: packageRelativePathSchema.optional(),
|
||||||
|
ui: packageRelativePathSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -87,9 +101,22 @@ function reportDuplicates(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one manifest shape Core accepts. Adding `entrypoints.ui` is an incompatible shape change —
|
||||||
|
* version 3's `entrypoints` object is `.strict()` and rejects `ui` outright — so the canonical
|
||||||
|
* manifest moves to `manifestVersion: 4` rather than reinterpreting version 3 (ADR 0021).
|
||||||
|
*
|
||||||
|
* Version 3, and every other version, fails validation: there is no compatibility adapter, no
|
||||||
|
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||||
|
* (ADR 0006) and version 2 by version 3 (ADR 0014). One manifest document can therefore never be
|
||||||
|
* read as two different versions. Upgrading a backend-only version 3 manifest is a one-character
|
||||||
|
* edit: no field added by version 4 is required.
|
||||||
|
*/
|
||||||
|
export const MODULE_MANIFEST_VERSION = 4;
|
||||||
|
|
||||||
export const moduleManifestSchema = z
|
export const moduleManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
manifestVersion: z.literal(3),
|
manifestVersion: z.literal(MODULE_MANIFEST_VERSION),
|
||||||
id: moduleIdSchema,
|
id: moduleIdSchema,
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
description: z.string().trim().min(1).max(500).optional(),
|
description: z.string().trim().min(1).max(500).optional(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
MODULE_LIFECYCLE_STAGES,
|
MODULE_LIFECYCLE_STAGES,
|
||||||
|
MODULE_MANIFEST_VERSION,
|
||||||
packageRelativePathSchema,
|
packageRelativePathSchema,
|
||||||
parseModuleManifest,
|
parseModuleManifest,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
|
|
||||||
function validManifest(): Record<string, unknown> {
|
function validManifest(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 3,
|
manifestVersion: 4,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -193,10 +194,10 @@ describe("module manifest contract", () => {
|
|||||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
|
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a version 2 manifest rather than silently interpreting it as version 3", () => {
|
it("rejects a version 2 manifest rather than silently interpreting it as a later version", () => {
|
||||||
// manifestVersion 2's own permissions shape (a bare string array) is also what version 3
|
// manifestVersion 2's own permissions shape (a bare string array) is also what every later
|
||||||
// must reject — this manifest is invalid twice over, deliberately, since a real version 2
|
// version must reject — this manifest is invalid twice over, deliberately, since a real
|
||||||
// manifest is exactly this shape.
|
// version 2 manifest is exactly this shape.
|
||||||
const version2Manifest = {
|
const version2Manifest = {
|
||||||
...validManifest(),
|
...validManifest(),
|
||||||
manifestVersion: 2,
|
manifestVersion: 2,
|
||||||
@@ -206,6 +207,25 @@ describe("module manifest contract", () => {
|
|||||||
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
|
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a version 3 manifest rather than silently interpreting it as version 4", () => {
|
||||||
|
// A backend-only version 3 manifest is otherwise byte-identical to a valid version 4 one:
|
||||||
|
// only the version literal separates them, and it is not reinterpreted.
|
||||||
|
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||||
|
expect(MODULE_MANIFEST_VERSION).toBe(4);
|
||||||
|
for (const manifestVersion of [1, 2, 3, 5, "4", 4.5, null]) {
|
||||||
|
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
safeParseModuleManifest({ ...validManifest(), manifestVersion: MODULE_MANIFEST_VERSION })
|
||||||
|
.success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a bare permission-id string array instead of complete declarations", () => {
|
it("rejects a bare permission-id string array instead of complete declarations", () => {
|
||||||
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
||||||
|
|
||||||
@@ -325,4 +345,115 @@ describe("module manifest contract", () => {
|
|||||||
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
|
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
|
||||||
expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
|
expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("module-owned UI entrypoint (ADR 0021)", () => {
|
||||||
|
it("accepts a backend-only module that declares no UI at all", () => {
|
||||||
|
const manifest = parseModuleManifest(validManifest());
|
||||||
|
|
||||||
|
expect(manifest.entrypoints.ui).toBeUndefined();
|
||||||
|
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a declared compiled UI entrypoint alongside the server entrypoint", () => {
|
||||||
|
const manifest = parseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
|
||||||
|
// Declaring UI never alters the backend contract: a product module is one module, not two.
|
||||||
|
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["../outside/ui.js", "escapes the package boundary"],
|
||||||
|
["/absolute/ui.js", "is an absolute path"],
|
||||||
|
["./dist/../../ui.js", "traverses out through a parent segment"],
|
||||||
|
["file:///dist/ui/index.js", "is a URL rather than a package-relative path"],
|
||||||
|
["./dist\\ui\\index.js", "uses backslash separators"],
|
||||||
|
["./dist/ui/index.js?raw", "carries a query string"],
|
||||||
|
["", "is empty"],
|
||||||
|
])("rejects a UI entrypoint that %s", (ui) => {
|
||||||
|
const manifest = {
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: { server: "./dist/server/index.js", ui },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
42,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
["./dist/ui/index.js"],
|
||||||
|
{ entry: "./dist/ui/index.js" },
|
||||||
|
])("rejects a non-string UI entrypoint %s", (ui) => {
|
||||||
|
const manifest = {
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: { server: "./dist/server/index.js", ui },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects unknown entrypoint keys, so UI is the only new one", () => {
|
||||||
|
for (const unknownEntrypoint of ["dashboard", "surface", "client", "web", "renderers"]) {
|
||||||
|
const manifest = {
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
[unknownEntrypoint]: "./dist/ui/index.js",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares no grouping, folder, parent, or menu field for a module to own", () => {
|
||||||
|
// Dashboard organization is presentation configuration, never module architecture
|
||||||
|
// (ADR 0021). The manifest must offer no place to encode a hierarchy.
|
||||||
|
for (const presentationField of [
|
||||||
|
"group",
|
||||||
|
"folder",
|
||||||
|
"parent",
|
||||||
|
"menu",
|
||||||
|
"navigation",
|
||||||
|
"icon",
|
||||||
|
"order",
|
||||||
|
]) {
|
||||||
|
const manifest = { ...validManifest(), [presentationField]: "media" };
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the module dependency graph flat: UI adds no parent/child relationship", () => {
|
||||||
|
const manifest = parseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// A module that ships UI still expresses relationships only through genuine module and
|
||||||
|
// capability dependencies — the same two mechanisms a backend-only module uses.
|
||||||
|
expect(Object.keys(manifest.dependencies.modules.required)).toEqual([]);
|
||||||
|
expect(Object.keys(manifest.dependencies.modules.optional)).toEqual([]);
|
||||||
|
expect(Object.keys(manifest)).not.toContain("children");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still supports a genuine module dependency for a module that ships UI", () => {
|
||||||
|
const manifest = parseModuleManifest({
|
||||||
|
...validManifest(),
|
||||||
|
dependencies: {
|
||||||
|
modules: { required: { people: "^0.1.0" } },
|
||||||
|
capabilities: { required: { "people.records": "^2.0.0" } },
|
||||||
|
},
|
||||||
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manifest.dependencies.modules.required).toEqual({ people: "^0.1.0" });
|
||||||
|
expect(manifest.dependencies.capabilities.required).toEqual({ "people.records": "^2.0.0" });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ route (`GET /records`, declared in `register`) that reads that table through
|
|||||||
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
||||||
provisioning are intentionally not included.
|
provisioning are intentionally not included.
|
||||||
|
|
||||||
|
This template ships backend behavior only, which is a choice rather than a constraint: as of
|
||||||
|
[ADR 0021](../../docs/architecture/decisions/0021-modules-as-hostable-products.md) a module may also
|
||||||
|
declare an optional compiled `entrypoints.ui` artifact. The template does not, because no host loads
|
||||||
|
a UI entrypoint yet — see
|
||||||
|
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint) for
|
||||||
|
what is and is not supported today.
|
||||||
|
|
||||||
To create a module inside this repository:
|
To create a module inside this repository:
|
||||||
|
|
||||||
1. Copy this directory to `modules/<module-id>`.
|
1. Copy this directory to `modules/<module-id>`.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 3,
|
"manifestVersion": 4,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example Module",
|
"name": "Example Module",
|
||||||
"description": "A minimal backend-only Ekklesia module template.",
|
"description": "A minimal Ekklesia module template with backend behavior only.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.1.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user