docs: record the five Phase 8 prerequisites, and report contributions

Bring the living documentation in line with what the five passes actually
shipped, and correct the claims they superseded rather than leaving them to
mislead: the module UI context is six members, not five; module styling is
compiled CSS, not inline objects only; the strict manifest is version 7.

Also close a silent failure the new contribution mechanism introduces. A
contribution whose target capability nobody provides — or whose provider moved
outside its declared range — is not published, and the manifest is valid and
the artifact exists, so an operator sees a screen that renders nothing with no
error anywhere. `pnpm modules:validate` now lists each declared contribution
with its capability and range and says whether a compatible provider is
installed, and Doctor's discovery check reports the same per module. Both
derive from the publication registry the API serves from, so neither can
disagree with what a running platform would actually publish.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-27 16:16:01 -05:00
co-authored by Claude Opus 5
parent ca3f37d5f9
commit 20d5128bad
10 changed files with 375 additions and 11 deletions
+72 -2
View File
@@ -289,7 +289,8 @@ artifacts cannot drift.
externalizing it would make every host supply a compatible React — defeating standalone hosting. The externalizing it would make every host supply a compatible React — defeating standalone hosting. The
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template); accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
sharing one runtime later is a build optimization that does not change the contract. Styling is React sharing one runtime later is a build optimization that does not change the contract. Styling is React
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds, `.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
and installing a module or starting the API never triggers one. and installing a module or starting the API never triggers one.
@@ -488,7 +489,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 6 after ADR 0033 added the optional display declaration. manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
browser contributions.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and **Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment no key management, so a `secret` key's value is supplied by the deployment
@@ -792,6 +794,74 @@ unproven launch-blockers rather than existing guarantees. Read
[Security model and deployment trust boundaries](docs/development/security.md) before installing a [Security model and deployment trust boundaries](docs/development/security.md) before installing a
module or exposing the API. module or exposing the API.
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
None of them is a feature module, and none of them names one.
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
badge, toast, and empty/error/loading states), every value a token reference, with real
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
exactly two shared roles to modules by name and pins both design packages beneath every other role.
The Dashboard and display install the defaults at their document root; custom properties inherit into
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
theme editor, brand builder, second theme, or component catalogue exists.**
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared
range; a contribution that fails is absent rather than broken. A consumer discovers through
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
database, storage, or credential — and loading one grants its module nothing.
Phase 8 pass 4 closes the object-storage write path
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
oversize is refused before authorization, the buffered path now counts what arrived rather than
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
every contributed renderer that display mounted, network-first so a screen can never be trapped on
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
stated rather than papered over — a display cannot render module content while offline, because
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
## Quick start ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+22
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { PermissionRegistryError } from "../authorization/index.js"; import { PermissionRegistryError } from "../authorization/index.js";
import { import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModuleSchemas, discoverModuleSchemas,
ModuleDiscoveryError, ModuleDiscoveryError,
ModulePermissionCompositionError, ModulePermissionCompositionError,
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
return; return;
} }
// Built from the same registry the API publishes from, so this command can never disagree with
// what a running platform would actually serve. A contribution whose target capability nobody
// provides — or whose provider moved outside its declared range — is otherwise silent: the
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
const publication = createModuleUiPublicationRegistry(modules);
const compatibleContributions = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
console.log( console.log(
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`, `Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
); );
for (const discoveredModule of modules) { for (const discoveredModule of modules) {
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`); console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
for (const contribution of discoveredModule.contributions) {
const resolved = compatibleContributions.has(
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
);
console.log(
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
resolved ? "compatible provider installed" : "no compatible provider installed"
}`,
);
}
} }
} }
+30 -1
View File
@@ -57,6 +57,7 @@ import {
createModuleConfigurationStore, createModuleConfigurationStore,
createModuleLifecycleDatabase, createModuleLifecycleDatabase,
createModuleLifecycleStore, createModuleLifecycleStore,
createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModuleSchemas, discoverModuleSchemas,
discoverModules, discoverModules,
@@ -316,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) }); return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
} }
/**
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
* — how many of them have a compatible provider among the installed set.
*
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
* capability nobody provides, or whose provider moved outside its declared range, is simply not
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
* are derived from the same publication registry the API serves from, so this can never disagree
* with what the running platform would actually publish.
*
* Static only, like every other Doctor check: it reports what the *manifests* support and never
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
*/
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] { function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
const publication = createModuleUiPublicationRegistry(modules);
const compatible = new Set(
publication
.listContributions()
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
);
return modules.map((module) => { return modules.map((module) => {
const id = module.manifest.id; const id = module.manifest.id;
const version = module.manifest.version; const version = module.manifest.version;
@@ -326,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
) { ) {
throw new TypeError("Unsafe module diagnostic value."); throw new TypeError("Unsafe module diagnostic value.");
} }
return `${id} ${version}`; const declared = module.contributions.length;
if (declared === 0) {
return `${id} ${version}`;
}
const resolved = module.contributions.filter((contribution) =>
compatible.has(`${id}\u0000${contribution.id}`),
).length;
return `${id} ${version} · ${declared} browser contribution${
declared === 1 ? "" : "s"
} (${resolved} with a compatible provider)`;
}); });
} }
+88
View File
@@ -1472,3 +1472,91 @@ describe("Ekklesia Doctor — object storage", () => {
expect(check(report, "object-storage.bindings").status).toBe("skipped"); expect(check(report, "object-storage.bindings").status).toBe("skipped");
}); });
}); });
/**
* A browser contribution whose target capability nobody provides is otherwise silent: the manifest
* is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041).
* Doctor names it.
*/
describe("Doctor reports declared browser contributions", () => {
function contributing(
id: string,
contribution: { capability: string; capabilityRange: string },
): DiscoveredModule {
const base = discoveredModule(id);
return {
...base,
contributions: [
{
artifactPath: `/unpublished/${id}/contributions/slide.js`,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: `${id}.slide`,
version: "1.0.0",
},
],
};
}
function providing(id: string, capability: { id: string; version: string }): DiscoveredModule {
const base = discoveredModule(id);
return {
...base,
manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"],
};
}
async function detailsFor(modules: readonly DiscoveredModule[]): Promise<readonly string[]> {
const report = await diagnoseEkklesia(
options(),
dependencies({
discoverModules: vi.fn(async () => Object.freeze([...modules])),
discoverSchemas: vi.fn(async () => schemaAggregate(modules)),
}),
);
return check(report, "modules.discovery").details;
}
it("says nothing extra for a module that contributes nothing", async () => {
expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]);
});
it("reports a contribution whose provider satisfies its declared range", async () => {
const details = await detailsFor([
providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }),
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]);
expect(details).toEqual([
"signage 0.1.0",
"announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)",
]);
});
it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => {
expect(
await detailsFor([
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]),
).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]);
expect(
await detailsFor([
providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }),
contributing("announcements", {
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
}),
]),
).toEqual([
"signage 0.1.0",
"announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)",
]);
});
});
+81 -2
View File
@@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
independent. independent.
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
dependencies, consumed by module authors and hosts alike. Its host context is five members — dependencies, consumed by module authors and hosts alike. Its host context was five members at that
checkpoint and is six today (ADR 0041) —
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through `moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
`api.request(path)` with a path relative to the module's own API root, never by constructing `api.request(path)` with a path relative to the module's own API root, never by constructing
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible. Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
@@ -244,7 +245,8 @@ it, and hosts perform their own dynamic import.
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler `src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
option in one `tooling` package so module authors write no build configuration. React is bundled option in one `tooling` package so module authors write no build configuration. React is bundled
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host; rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040),
and a stylesheet import then failed the build rather than emitting an
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
@@ -573,3 +575,80 @@ An authenticated result preserves the response, session, and mounted UI. Display
remains deferred and the display bundle gains no Dashboard login. Public Website routing, remains deferred and the display bundle gains no Dashboard login. Public Website routing,
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling, production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
and the missing production first-user creation path are also outside this pass. and the missing production first-user creation path are also outside this pass.
**Phase 8 supplies the five platform prerequisites the first production feature module needs.** None
of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by
something other than a feature module.
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md) adds the design layer the platform
never had. `@ekklesia/design-tokens` is one validated, enumerable registry of **semantic** tokens —
`background.raised`, `text.danger`, `state.hover-background` — where a token's category and
`--ekklesia-*` identifier are *derived* from its name and every value must be a bounded literal, so a
registry that constructs is one that can be emitted as CSS without escaping. That validation is
exactly what makes a future theme editor implementable rather than aspirational: the values it would
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
elements a real module dashboard cannot be built without, every value a token reference, with real
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
else. The Dashboard and display install the defaults at their document root; custom properties
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
run time and nothing for a host to forget to pass.
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) reverses ADR 0023's
inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports
plain `.css`; the canonical builder compiles it **into** the declared artifact as text, so there is
still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting
against. Two rules are enforced with their own codes: a module stylesheet may reference
`--ekklesia-*` and never declare one, and every `@import` must resolve locally. The generic loader
then mounts **every** browser artifact inside its own open Shadow DOM root with its own stylesheet
installed, unconditionally, because a conditional isolation boundary is one a module can fall out of
by editing a source file. That root is a correctness boundary for *styles* and is never described as
a sandbox: it is `open` precisely because module code is trusted, same-realm code that could reach
`document` anyway. The browser mount ABI is versioned (`MODULE_UI_ABI_VERSION`, stamped by
`defineModuleUi`) with no dual acceptance; migration is one `pnpm build`.
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
renderer to a capability another module provides — the browser half of the capability system Core has
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target
capability, a compatible range, and one artifact. That is the complete list: there is no payload
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
identical loop every other compiled entrypoint uses; publication requires that *another* eligible
module provides the capability within the declared range, and a contribution that fails is absent
rather than broken. A consumer discovers through `ModuleUiHostContext.contributions` and mounts by
id — never by building a publication URL — and can only see contributions aimed at a capability it
itself provides. The input is opaque JSON validated for serializability and nothing else, so the
domain contract stays entirely between the two modules. A contributed renderer receives its own id
and that input: no API client, realtime, principal, configuration, database, storage, or credential,
and loading one grants its module nothing.
[ADR 0042](decisions/0042-bounded-streaming-request-bodies.md) closes the write path ADR 0031 left
open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the *read* path, but declined
signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at
1 MiB. A route now declares `bodyMode`: `buffered` (the default, and what every existing route
meant) keeps that ceiling and materializes the body before the handler runs; `streamed` raises it to
32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather
than inferred from the size, because inferring it would silently change how an author's own handler
receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one
25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's
own limiter replaces the buffering dependency: a declared oversize is refused before authorization,
the buffered path now counts what actually arrived rather than trusting a header, and a streamed
overflow errors the stream, cancels the source, and replaces the response with the same sanitized
`413` even if the handler swallowed the error.
[ADR 0043](decisions/0043-display-offline-code-fallback.md) gives the display host-owned offline
resilience, scoped precisely to **the shell and the declared code**. A service worker scoped to
`/display/` caches the page, the host script, the module's display artifact, and every contributed
renderer that display mounted — and never the descriptor, `/api/v1/principal`, any API response, the
realtime stream, a pairing exchange, or any cross-origin request, which is what a signed
object-delivery URL is. The consequence is stated rather than papered over: a display cannot render
module content while offline, because mounting needs an authorization-bearing descriptor that is
never cached, so a revoked device stays revoked and cached code can never manufacture current
authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline
state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is
network-first everywhere, so a screen can never be trapped on obsolete code.
Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
included. Nothing in this pass claims any of them exists.
+1 -1
View File
@@ -1232,7 +1232,7 @@ its role-specific URL only from the protected descriptor below.
## Module display host ## Module display host
Manifest version 6 adds one independently optional display declaration The manifest carries one independently optional display declaration
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)): ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
```json ```json
+3 -2
View File
@@ -137,7 +137,8 @@ enforces those permissions while the module is disabled, because it mounts no ro
## Module permission composition ## Module permission composition
A module manifest's `permissions` array (`manifestVersion: 4`, A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
manifest is version 7 — see
[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
@@ -667,7 +668,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: 4` acceptance and version - `packages/contracts/tests/module-manifest.test.ts` — exact-version 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.
+6 -2
View File
@@ -37,7 +37,10 @@ the root verification command discovers them automatically.
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A `pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
missing or empty modules directory is valid and reports zero installed modules. Installed modules missing or empty modules directory is valid and reports zero installed modules. Installed modules
must produce the compiled JavaScript entrypoints declared by their static manifests. Optional must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
declared browser contributions are listed with their target capability and range, and marked
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
foreign-key isolation; this command does not connect to PostgreSQL or run migrations. foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
@@ -82,7 +85,8 @@ not a broken deployment).
Doctor reports a fixed, deterministic set of checks: Doctor reports a fixed, deterministic set of checks:
- The active Node.js version against the repository's declared engine range. - The active Node.js version against the repository's declared engine range.
- Static module discovery and dependency-first ordering. - Static module discovery and dependency-first ordering. A module that declares browser
contributions also reports how many of them have a compatible provider among the installed set.
- Schema discovery, including validated module and table counts. This imports compiled schema - Schema discovery, including validated module and table counts. This imports compiled schema
definitions, which the module contract requires to perform no configuration reads, I/O, or definitions, which the module contract requires to perform no configuration reads, I/O, or
database connection. database connection.
+1 -1
View File
@@ -201,7 +201,7 @@ separate deployment and threat-model decision.
### A display page is authorized; its artifact bytes are not authority ### A display page is authorized; its artifact bytes are not authority
Manifest version 6 binds a display artifact to one permission declared by the same module The manifest binds a display artifact to one permission declared by the same module
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
a `public` baseline: the current display host requires an ordinary authenticated cookie principal, a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
and public/device identity is not approximated with an anonymous exception. Both the stable and public/device identity is not approximated with an anonymous exception. Both the stable
+71
View File
@@ -69,6 +69,77 @@ protocol-relative, backslash-prefixed, and scheme-bearing values cannot become n
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
of the separate display bundle. of the separate display bundle.
Phase 8 adds evidence for the five platform prerequisites.
**Design tokens and shared elements** (ADR 0039). The token registry's tests prove it is
deterministic and enumerable, that every token derives a unique `--ekklesia-*` identifier from its
name and carries a label, description, and category, that each status family pairs a colour with a
non-colour symbol, and that a duplicate name, an unknown category, an invalid or single-segment name,
and a value carrying a declaration terminator, a rule block, a comment, an at-rule, `url(`,
`!important`, an escape, a control character, or markup are each rejected — with an error that never
echoes the rejected declaration. The elements' tests assert accessible structure (label/hint/error
association, icon-button naming, tab roles and arrow-key movement, announced error and loading
states), that status never depends on colour alone, that a dialog renders in place rather than
portalling out of an isolated root, and — mechanically — that the stylesheet and every component
source contain no literal colour, font stack, or theme value and reference only registered tokens.
Both packages compile module-shaped source under the exact browser project a module uses, where
`process.env` must fail to typecheck, so the browser boundary is measured rather than asserted.
**Module stylesheets and artifact isolation** (ADR 0040). The builder's tests prove a plain
stylesheet is compiled into the declared artifact with no `.css` emitted beside it, that a local
`@import` is inlined, and that an unresolved `@import`, a stylesheet declaring an `--ekklesia-*`
variable, and a non-CSS dialect are each rejected with their own sanitized code — while a private
alias derived from a registered token is accepted. The loader's tests prove every artifact mounts
inside an open shadow root, that the artifact receives that root rather than the host's element, that
exactly one `<style>` is installed per mount and never accumulates across remounts, that the host's
base rules precede the artifact's so a display can override `:host`, that unmount removes the whole
root even when the module's cleanup throws, that a failed mount leaves neither DOM nor styles behind,
that an environment without Shadow DOM fails closed rather than mounting unisolated, and that an
artifact built against the previous ABI is refused with `MODULE_UI_ABI_UNSUPPORTED`. The template's
suite builds real React artifacts through the canonical pipeline and asserts the compiled stylesheet
travels inside the one declared file.
**Browser contributions** (ADR 0041). Manifest tests cover namespacing, the single local segment,
duplicate ids and artifact paths, collision with an entrypoint, and a contribution targeting a
capability its own module provides. Discovery tests prove a contribution artifact inherits the exact
path, symlink, containment, extension, and file-kind protections every other compiled entrypoint has,
including two contributions linked to one file. Publication tests prove a contribution is published
only when another eligible module provides its capability *within the declared range*, that the
contributing module never sees its own contribution, that a lookup fails closed on an unknown,
mismatched, or non-string identity, and that the published record carries no domain vocabulary at
all. Route tests prove the artifact is served byte-for-byte at a URL that cannot select a neighbour,
and that an unknown id, another module's id, a traversal, a source map, a wrong version, an absent
provider, and an incompatible provider all collapse into one indistinguishable `404`. The loader's
tests cover mount, update-without-remount, a renderer that declares no `update`, deterministic and
idempotent unmount, a sanitized update failure, and input that is not genuinely JSON — a `Date`, a
function, `NaN`, `undefined`, and a cycle — refused *before* anything is imported and without ever
echoing the consumer's data. The flagship shell fixture drives the whole relationship: a `signage`
module discovers, mounts, updates, and unmounts an `announcements` React renderer with neither module
importing the other, the consumer seeing identity but never a URL, the host resolving publication,
and a contribution id the server withheld refused without a fetch.
**Bounded streaming uploads** (ADR 0042). Contract tests pin the two ceilings, that `maxBodyBytes` is
checked against the ceiling the declared mode selects, that 32 MiB holds a 25 MiB image and one byte
more is refused, and that no mode admits an unbounded body. HTTP tests cover exact-boundary success,
a declared `Content-Length` refused before the handler runs, a misleading smaller `Content-Length`
that cannot smuggle a megabyte past the limit, a chunked overflow, a `413` that still wins when the
handler swallowed the stream error, proof that the body is genuinely not buffered (a handler sees the
first chunk before the last is produced), an aborted upload surfacing as an ordinary failure rather
than a `413`, an unread body being released, authorization ordering in both directions, and a
rejection that names no limit, size, or body content. The live suite uploads a real 25 MiB body over
a real socket end to end and proves the server is healthy immediately afterwards.
**Display offline fallback** (ADR 0043). The worker's caching logic is exercised directly, because it
touches no global: first online load, offline reload after one successful visit, a missing uncached
artifact failing honestly, contributed-renderer fallback, version upgrade with pruning that keeps
every artifact of the current version and never touches another module's, superseded cache
generations removed while unrelated ones survive, network-first behaviour picking up rebuilt bytes,
and an explicit inventory proving the descriptor, principal, module API, realtime stream, pairing
exchange, auth routes, cross-origin signed object URLs, and the worker itself are **never** stored —
so a revoked device's refusal is what a reconnected display sees. Page-level tests cover the quiet
offline indicator, a cache fallback reported while the browser still claims to be online, automatic
re-resolution when the network returns, and a revoked device dropping back to pairing on reconnect.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live `pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database. database.