Files
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

103 lines
4.9 KiB
JavaScript

import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import esbuild from "esbuild";
/**
* Builds the Ekklesia shell into a static browser application.
*
* This is **not** the module UI build pipeline, and the two must not be conflated. ADR 0023's
* `@ekklesia/module-ui-build` compiles a *module's* `src/ui/index.tsx` into the artifact its
* manifest declares, is owned by the platform so module authors write no bundler configuration, and
* runs when a module is packaged. This script builds *this application*, is owned by this package
* alone, and knows nothing about manifests. They happen to share a bundler; they share no
* configuration, no contract, and no output.
*
* Installed module UIs are deliberately **not** bundled in. The shell has no import of any module,
* so there is nothing for esbuild to reach: every module artifact is fetched at run time from the
* URL the inventory returned. That is the property that makes modules independently installable, and
* bundling one would silently destroy it.
*/
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const outputDirectory = path.join(packageDirectory, "dist", "browser");
const serve = process.argv.includes("--serve");
/**
* Development only: the API origin the development server writes into its copy of the Dashboard page,
* exactly as `apps/api` does at startup in production (ADR 0045). Empty means "the API is on this
* page's own origin". Set it to an absolute origin for split-origin development
* (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and add this page's own origin to
* `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS and the session cookie travels.
* A production build never reads it: the built page always carries an empty value, and the bundle
* carries no origin at all.
*/
const apiBaseUrl = serve ? (process.env.EKKLESIA_API_BASE_URL ?? "") : "";
const API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
/** @type {import("esbuild").BuildOptions} */
const buildOptions = {
absWorkingDir: packageDirectory,
// Everything the shell needs is bundled; a browser resolves nothing at run time except the module
// artifacts it is told about, which are real URLs rather than bare specifiers.
bundle: true,
define: {
// React reads this at module scope and a browser has no `process`.
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
},
entryPoints: {
// Emitted under the exact path `apps/api` serves it from (ADR 0044), so the development server's
// `servedir` and production answer the same absolute URL `index.html` names.
"assets/ekklesia-dashboard/app": path.join(packageDirectory, "src", "browser", "main.tsx"),
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
// The display service worker (ADR 0043). A separate entry because a worker is a separate
// realm with its own globals; bundling it into the page would make it unregisterable and its
// `self` meaningless.
"display/sw": path.join(packageDirectory, "src", "display", "sw.ts"),
},
format: "esm",
jsx: "automatic",
logLevel: "info",
// Unminified, matching the module UI build: an operator is expected to be able to read what their
// installation actually serves, and nothing else in this repository minifies.
minify: false,
outdir: outputDirectory,
platform: "browser",
sourcemap: true,
// Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree.
target: "es2022",
};
await rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true });
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
const dashboardPage = await readFile(path.join(packageDirectory, "index.html"), "utf8");
if (dashboardPage.split(API_ORIGIN_PLACEHOLDER).length !== 2) {
throw new Error("index.html must contain exactly one empty ekklesia-api-origin meta element.");
}
await writeFile(
path.join(outputDirectory, "index.html"),
dashboardPage.replace(
API_ORIGIN_PLACEHOLDER,
`<meta content="${apiBaseUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;")}" name="ekklesia-api-origin" />`,
),
);
await copyFile(
path.join(packageDirectory, "display.html"),
path.join(outputDirectory, "display", "index.html"),
);
if (serve) {
const context = await esbuild.context(buildOptions);
await context.watch();
const { host, port } = await context.serve({ servedir: outputDirectory });
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
console.log(
apiBaseUrl === ""
? "API origin: this page's own origin. Set EKKLESIA_API_BASE_URL for a split-origin API."
: `API origin: ${apiBaseUrl}`,
);
} else {
await esbuild.build(buildOptions);
console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`);
}