feat: support split Dashboard and API origins (ADR 0045)
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
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cbf5962d04
commit
f63f80f2f0
@@ -44,6 +44,22 @@ describe("Dashboard host asset locator", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("carries exactly one empty API-origin meta element for the serving API to fill (ADR 0045)", async () => {
|
||||
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
||||
|
||||
expect(html.match(/name="ekklesia-api-origin"/gu)).toHaveLength(1);
|
||||
expect(html).toContain('<meta content="" name="ekklesia-api-origin" />');
|
||||
});
|
||||
|
||||
it("bakes no API origin into the bundle: the build defines nothing but NODE_ENV", async () => {
|
||||
const buildScript = await readFile(
|
||||
path.join(packageDirectory, "scripts/build-browser.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(buildScript).not.toContain("__EKKLESIA_API_BASE_URL__");
|
||||
});
|
||||
|
||||
it("builds the Dashboard entry at the exact path the locator names", async () => {
|
||||
const buildScript = await readFile(
|
||||
path.join(packageDirectory, "scripts/build-browser.mjs"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
loadHostPrincipal,
|
||||
parseDashboardNavigation,
|
||||
parseHostPrincipal,
|
||||
resolveApiOrigin,
|
||||
} from "../src/host-protocol.js";
|
||||
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
|
||||
|
||||
@@ -101,6 +102,39 @@ function navigationWithModule(module: unknown): unknown {
|
||||
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
|
||||
}
|
||||
|
||||
describe("host protocol — API origin (ADR 0045)", () => {
|
||||
const pageOrigin = "https://dash.example.test";
|
||||
|
||||
it("uses the page's own origin when the page declares none (same-origin topology)", () => {
|
||||
expect(resolveApiOrigin(undefined, pageOrigin)).toBe(pageOrigin);
|
||||
expect(resolveApiOrigin(null, pageOrigin)).toBe(pageOrigin);
|
||||
expect(resolveApiOrigin("", pageOrigin)).toBe(pageOrigin);
|
||||
});
|
||||
|
||||
it("uses a declared canonical origin (split topology)", () => {
|
||||
expect(resolveApiOrigin("https://api.example.test", pageOrigin)).toBe(
|
||||
"https://api.example.test",
|
||||
);
|
||||
expect(resolveApiOrigin("http://127.0.0.1:3000", pageOrigin)).toBe("http://127.0.0.1:3000");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a trailing slash", "https://api.example.test/"],
|
||||
["a path", "https://api.example.test/api"],
|
||||
["a query", "https://api.example.test?x=1"],
|
||||
["credentials", "https://user:[email protected]"],
|
||||
["a non-canonical spelling", "HTTPS://API.example.test"],
|
||||
["a default port spelled out", "https://api.example.test:443"],
|
||||
["another scheme", "javascript:alert(1)"],
|
||||
["a protocol-relative value", "//api.example.test"],
|
||||
["a relative value", "/api"],
|
||||
["surrounding whitespace", " https://api.example.test"],
|
||||
["something that is not a URL", "api.example.test"],
|
||||
])("refuses %s, so the page fails closed", (_case, declared) => {
|
||||
expect(resolveApiOrigin(declared, pageOrigin)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("host protocol — descriptor validation", () => {
|
||||
it("parses a well-formed descriptor out of the navigation view", () => {
|
||||
const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0]));
|
||||
|
||||
@@ -120,7 +120,6 @@ describe("the shell builds no module knowledge into its bundle", () => {
|
||||
absWorkingDir: packageDirectory,
|
||||
bundle: true,
|
||||
define: {
|
||||
__EKKLESIA_API_BASE_URL__: '""',
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
},
|
||||
entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")],
|
||||
@@ -200,7 +199,6 @@ describe("the display host is a separate generic browser bundle", () => {
|
||||
absWorkingDir: packageDirectory,
|
||||
bundle: true,
|
||||
define: {
|
||||
__EKKLESIA_API_BASE_URL__: '""',
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
},
|
||||
entryPoints: [path.join(sourceDirectory, "display", "main.tsx")],
|
||||
|
||||
Reference in New Issue
Block a user