Files
Ekklesia/surfaces/shell/src/display/main.tsx
T
ekklesiaandClaude Opus 5.5 f63f80f2f0
CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
feat: support split Dashboard and API origins (ADR 0045)
An installation can now serve the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image and with configuration alone.

- The Dashboard learns its API origin at run time: the built page carries
  one empty <meta name="ekklesia-api-origin">, which the API fills with its
  identity base URL's origin at startup, before computing the page's ETag.
  The shell accepts only a canonical http(s) origin and otherwise fails
  closed without making a request. The build-time EKKLESIA_API_BASE_URL is
  gone from the bundle; the development server injects the same element.
- The display host stays on the API's origin, so its device cookies,
  requests, and offline cache are unchanged.
- The API trusts its own origin (trusted origins plus the identity base
  URL), as Better Auth already does, so the display page's mutations pass
  the origin check without operators listing it.
- Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup
  does (it previously skipped it), reports the topology without printing
  hostnames, and warns when split hostnames are not siblings.
- The module UI contract states the CORS header limit (safelisted headers
  plus Content-Type, safelisted response headers only).
- Reference deployment: Caddyfile.split.example (Dashboard hostname serves
  / and its script; API hostname serves /api/*, /display/*, and the
  display script; each refuses the other's paths) and
  EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples.
- The deploy smoke test runs either topology (CI runs both). The split run
  installs the module template as a test-only module, checks per-hostname
  routing and exact CORS, then drives a digest-pinned headless Chromium
  over the DevTools protocol, with no added dependency: sign-in on the
  Dashboard hostname, the session cookie host-only on the API hostname,
  the module UI imported cross-origin and its credentialed request
  succeeding, the display and its service worker on the API hostname, and
  sign-out.

ADR 0045 is marked Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 14:47:36 -05:00

80 lines
2.9 KiB
TypeScript

import { designToken, installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
/**
* The display host is always served from the API's own origin, in either topology (ADR 0045): its
* device cookies, pairing, descriptors, module requests, and realtime are all same-origin, and its
* offline cache stores only same-origin responses (ADR 0043).
*/
const config: EkklesiaHostConfig = {
apiBaseUrl: window.location.origin,
};
const container = document.getElementById("ekklesia-display");
if (!container) {
throw new Error("The Ekklesia display host requires a #ekklesia-display element.");
}
/**
* Offline resilience is registered as an enhancement and never as a prerequisite (ADR 0043): a
* browser without service workers, or a policy that refuses the registration, leaves a display that
* works exactly as it did before. The promise is deliberately not awaited — a screen must render
* while its worker is still installing.
*/
void registerDisplayServiceWorker(
typeof navigator === "undefined" ? undefined : navigator.serviceWorker,
);
/** The two real browser sources the connectivity monitor reads. Passed as data so nothing below the
* entry point touches a global. */
const connectivity: DisplayConnectivitySource = {
addEventListener: (type, listener) => window.addEventListener(type, listener),
initialOnline: navigator.onLine,
removeEventListener: (type, listener) => window.removeEventListener(type, listener),
...(typeof navigator === "undefined" || !navigator.serviceWorker
? {}
: { serviceWorkerMessages: navigator.serviceWorker }),
};
let moduleId: string | undefined;
try {
moduleId = parseDisplayRoute(window.location.pathname);
} catch {
moduleId = undefined;
}
/** The display is its own host root, so it installs the same default theme the Dashboard does; a
* module's display artifact then inherits the tokens into its isolated root (ADR 0039). */
installEkklesiaDesignTokens(document);
createRoot(container).render(
<>
<EkklesiaUiStyles />
{moduleId ? (
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
) : (
<div
data-testid="display-route-invalid"
style={{
alignItems: "center",
color: designToken("text.secondary"),
display: "flex",
fontFamily: designToken("typography.family-sans"),
height: "100%",
justifyContent: "center",
minHeight: "100dvh",
padding: designToken("spacing.lg"),
textAlign: "center",
}}
>
This display is unavailable.
</div>
)}
</>,
);