Phase 8/module platform prerequisites #40
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
const declared = module.contributions.length;
|
||||||
|
if (declared === 0) {
|
||||||
return `${id} ${version}`;
|
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)`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user