Phase 8/module platform prerequisites #40
Merged
Zayin_Admin
merged 7 commits from 2026-09-06 00:41:23 +00:00
phase-8/module-platform-prerequisites into main
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bb7a9eb351 |
fix: let the display worker ignore requests it does not handle
The fetch listener called `respondWith` for every request and re-issued the unrecognized ones itself. That routes the whole page through the worker, re-sends request bodies from a different context, and makes the worker's presence observable on requests it has no interest in. Classify synchronously and respond only for the shell and artifact URLs this worker actually owns. Not responding is what leaves the browser doing exactly what it would have done without a worker at all — which is the property the allowlist was written to preserve. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
20d5128bad |
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]> |
||
|
|
ca3f37d5f9 |
feat: let a display survive its network — as code, not as authorization
A display is the one surface expected to outlive its network: a television on a wall in a building whose uplink is a consumer router, with nobody standing in front of it. When that uplink drops and the screen reboots, the browser shows its own error page — not the display shell, not an Ekklesia message, nothing that recovers on its own, because there is no Ekklesia code left running to recover. Cache the shell and the declared code, in a service worker scoped to `/display/`: the page, the host script, the module's display artifact, and every contributed renderer that display actually mounted. All of them are code whose bytes already confer no authority, and all of them are addressed by a URL carrying an exact version. Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a module or platform API response, not the realtime stream, not a pairing exchange, and not any cross-origin request — which is what a signed object-delivery URL is. It is an allowlist rather than a deny list, so the cost of forgetting a new endpoint is that code goes uncached rather than that a permission decision gets stored. The consequence is stated rather than papered over: a display cannot render module content while offline. Mounting needs a descriptor, a descriptor is authorization-bearing, and it is never cached — so a revoked device stays revoked and cached code can never manufacture current authorization. What the cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline state and keeps retrying, that reconnecting mounts with nothing left to download, and that a hiccup fetching 600 KB of artifact after the tiny descriptor already resolved falls back to identical bytes instead of failing the display. Network-first everywhere, so a screen picks up new code the moment the server returns and can never be trapped on an obsolete build — the failure mode a cache-first worker produces on a screen nobody visits. Only a complete same-origin 200 is stored. Cache names carry a policy version and superseded generations are deleted; artifacts are pruned to the current version per module, keeping every artifact belonging to that version because a display artifact and its contributed renderers share one. The worker never caches itself. The page gets a small signal built from two sources, because either alone lies: `navigator.onLine` is optimistic behind a captive portal, and only the worker knows it actually served from cache. A display shows a quiet corner indicator rather than blanking a lobby wall, and resolves its descriptor again by itself when the network returns — nobody presses Retry on a lobby TV. Every caching decision lives in a module that touches no global, so the behaviour is exercised directly rather than through a worker a test runner cannot host. Registration is an enhancement: no service workers, plain HTTP, or a refusing policy all leave a display that works exactly as before. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
e36485bf99 |
feat: let a module route stream an image-sized upload, explicitly and bounded
ADR 0017 capped a module route at 1 MiB and said large media belonged to a future storage design. That design arrived — ADR 0030 gave modules a bucket, ADR 0031 took Ekklesia out of the read path — and it did not solve the write path: signed write URLs were deliberately declined, so the only way bytes reach a bucket is a module's own route, and a photograph is not 1 MiB. Extend the route contract rather than adding a second HTTP system. A route declares `bodyMode`: `buffered` (the default, and what every existing route already meant) keeps the 1 MiB ceiling and materializes the body before the handler runs; `streamed` raises the ceiling 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 mean an author who raised a limit silently changed how their own handler receives its body and when a 413 can reach their own code. 32 MiB holds one 25 MiB image plus bounded overhead and is also the platform's absolute ceiling: no route in any mode may declare more, and no unbounded option exists in the contract. The unit is one file per request — twenty images are twenty individually bounded requests, and nothing here can express a 500 MiB aggregate whose failure would waste everything already sent. Replace the buffering dependency with the platform's own limiter. A declared Content-Length over the limit is refused synchronously, before authorization and before a byte is read. Buffered mode now counts what actually arrives rather than trusting the header, which closes a gap the previous implementation had. Streamed mode wraps the body in a counting transform; on overflow the stream errors, the source is cancelled, and the response is replaced with the same sanitized 413 even if the handler swallowed the error — a limit a handler can swallow is not a limit. An unread body is cancelled so an abandoned upload releases its socket, and an abort stays an ordinary failed request rather than becoming a 413. Ordering is unchanged and now strictly better: in streamed mode no byte is read until the handler reads it, and the handler runs only after authorization succeeds, so an unauthorized caller cannot push an upload through at all. The live suite uploads a real 25 MiB body over a real socket end to end. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
c444ff570b |
feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
07835b7b56 |
feat: give module UI real stylesheets, and every artifact its own root
ADR 0023 refused stylesheet imports for a good reason: esbuild would emit a
`.css` sidecar beside the artifact, nothing would declare it, no host would
fetch it, and it would work in a local test and silently do nothing in a real
host. Inline style objects were the whole story. They cannot express
`:hover`, `:focus-visible`, `:disabled`, `@media`, or `@keyframes` — so an
accessible focus treatment was not expressible, and the shared elements added
in the previous commit are built from real CSS for exactly that reason.
Compile the stylesheet into the artifact instead of beside it. A module
imports plain `.css`; the canonical builder resolves local `@import`s through
a nested bundle and turns each file into its compiled text, which the author
declares as `defineModuleUi({ styles })`. One declared file still contains
everything: nothing extra to publish, nothing to fetch, and no way for CSS to
become an asset nobody serves. The build additionally fails if a CSS output
ever appears beside an artifact, so a future loader change cannot bring the
sidecar back. Two rules are enforced with their own codes: a module stylesheet
may reference `--ekklesia-*` and never declare one, and every `@import` must
resolve locally or the artifact would fetch styles at run time. Plain `.css`
only; other dialects are a toolchain decision needing their own ADR.
Then make the isolation real. The generic loader mounts every browser artifact
— primary, display, and later a contributed renderer — inside an open Shadow
DOM root it creates, installs the artifact's stylesheet there, and hands the
artifact an element inside it. Shadow DOM is the one mechanism a browser
actually enforces; a class prefix or a "scope your selectors" instruction is a
convention one mistake defeats, and the failure mode is a module silently
restyling the Dashboard. It applies unconditionally, including to artifacts
with no stylesheet, because a conditional boundary is one a module can fall
out of by editing a source file. Both wrappers are `display: contents` through
base rules installed before the artifact's own, so layout is unchanged and a
full-viewport display can still override `:host`. Unmount removes the whole
root in a `finally`, so a cleanup that throws cannot leave styles behind, and
remounting cannot accumulate a second copy.
This is a correctness boundary for styles and is not described as a sandbox:
the root is `open` precisely because module code is trusted, same-realm code
that could reach `document` anyway.
An artifact's DOM is no longer a direct child of the host's element, so the
mount ABI is versioned explicitly. `defineModuleUi` stamps
`MODULE_UI_ABI_VERSION` so it can never drift from the SDK the artifact
compiled against, and a version-1 artifact fails with its own code — "rebuild
your module", not a typo hunt. No dual acceptance, matching every manifest
cutover here. Migration is one `pnpm build`.
`pnpm boundaries` allows `.css` only under `src/ui/` and `src/display/`: a
stylesheet anywhere else in a module is the undeclared sidecar again, just
relocated.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
e03d38fcab |
feat: give every Ekklesia screen one set of design tokens and elements
Modules have owned their own UI since ADR 0021, but the platform has never said what that UI should look like. Every screen here — the Dashboard shell, the display host, the module template — invented its own hex colours, font stack, spacing, and radii, and they already disagreed. A module author had no vocabulary for "the ordinary panel background", so the only available answer was to invent one, and two modules would invent two. Add `@ekklesia/design-tokens`: one validated, enumerable registry of semantic tokens. A token declares a name, label, description, and default value; its category and `--ekklesia-*` identifier are derived from the name, so it can never claim one category while being named for another. Construction rejects duplicates, malformed names, unknown categories, empty or overlong text, and any value that is not a bounded literal — no declaration terminator, rule block, comment, at-rule, `url(`, `!important`, escape, control character, or markup. A registry that constructs can therefore be emitted as CSS without escaping, because nothing needing escaping can be in it. That validation is also what makes a future theme editor implementable rather than aspirational. Add `@ekklesia/ui`: button, icon button, text input, select, checkbox, field wrapper, 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 depends on colour alone. Deliberately small: an element earns its place when two consumers need it. The stylesheet is a string rather than a `.css` file so the identical bytes install into a document or an isolated artifact root, and so hover, disabled, and focus-visible are real CSS rather than JavaScript approximations of them. `pnpm boundaries` opens exactly two shared roles to modules, by name — every other role stays rejected — and pins both design packages to the bottom of the presentation stack. Both are browser-safe by construction, proven by a real compile of module-shaped source in which `process.env` must fail to typecheck. The shell installs the defaults at its document root for the Dashboard and the display; custom properties inherit into module artifacts, so a module reads `var(--ekklesia-…)` with nothing to import at run time and nothing for a host to forget to pass. Co-Authored-By: Claude Opus 5 <[email protected]> |