feat(shell): make module pages first-class top-level routes
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s

Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`),
beside `/settings` and `/account`, so the implementation concept
"module" never appears in a user-facing address. ADR 0057 is rewritten
to match.

- Core reserves the top-level names the platform actually serves today
  (`account`, `api`, `assets`, `display`, `settings`), and discovery
  refuses a module that declares one with `MODULE_ID_RESERVED`. That
  covers startup, `modules:validate`, and package verification. Tests in
  `apps/api` hold the set to every top-level route the API registers and
  every page the shell interprets.
- The API serves the Dashboard page at `/<module-id>` and below only for
  modules discovered with a Dashboard UI, enabled or not: a disabled
  module's bookmark still opens the Dashboard, which says it is
  unavailable. An arbitrary root path such as
  `/definitely-not-a-real-module` stays the sanitized 404.
- Both reference proxies forward every top-level path shaped like a
  module id and let the API decide; the split Dashboard hostname still
  refuses `/api`, `/display`, and `/assets`. Installing a module needs no
  proxy change.
- `/<module-id>/<page>` is already served and forwarded, so a later
  module sub-page contract needs no routing or deployment change; the
  shell shows "Page not found" there until it exists.
- The deploy smoke checks a module's address before install, while
  installed, while disabled, after removal, and after reinstall, plus
  unknown names and the old `/modules/…` form through the real proxy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 08:42:34 -05:00
co-authored by Claude Opus 5.5
parent 9afe9376c4
commit ba60f70835
26 changed files with 745 additions and 334 deletions
+27 -77
View File
@@ -4,60 +4,17 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DASHBOARD_PAGE_PREFIXES,
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
isDashboardPagePath,
parseDashboardPath,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("the Dashboard URL space", () => {
it("reserves exactly the platform's page namespaces, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PAGE_PREFIXES)).toBe(true);
expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/settings", "/account"]);
});
it("never reserves a namespace the API, display host, or host assets already own", () => {
for (const prefix of DASHBOARD_PAGE_PREFIXES) {
for (const owned of ["/api", "/display", "/assets"]) {
expect(prefix === owned || prefix.startsWith(`${owned}/`), prefix).toBe(false);
expect(owned.startsWith(`${prefix}/`), prefix).toBe(false);
}
}
});
it("recognizes the root and every path inside a reserved namespace as a Dashboard page", () => {
for (const pathname of [
"/",
"/modules",
"/modules/",
"/modules/signage",
"/modules/signage/timelines/abc",
"/settings",
"/settings/displays/add",
"/account",
"/account/appearance",
]) {
expect(isDashboardPagePath(pathname), pathname).toBe(true);
}
});
it("does not claim anything outside them, including look-alike prefixes", () => {
for (const pathname of [
"",
"/api/v1/principal",
"/display/signage",
"/assets/ekklesia-dashboard/app.js",
"/modulesx",
"/settings-old",
"/accounts",
"/index.html",
"/login",
"modules/signage",
]) {
expect(isDashboardPagePath(pathname), pathname).toBe(false);
}
describe("the Dashboard's own top-level pages", () => {
it("are exactly Settings and Account, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
});
@@ -66,40 +23,34 @@ describe("parseDashboardPath", () => {
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
});
it("names a module page by its id, tolerating one trailing slash", () => {
expect(parseDashboardPath("/modules/signage")).toEqual({
kind: "module",
moduleId: "signage",
});
expect(parseDashboardPath("/modules/signage/")).toEqual({
kind: "module",
moduleId: "signage",
});
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
});
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/modules/sign%61ge")).toEqual({
kind: "module",
moduleId: "signage",
});
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
});
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [
"",
"/modules",
"/modules/",
"/modules//",
"//modules/signage",
"/modules/signage//",
"//",
"//signage",
"/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/modules/signage/timelines",
"/modules/%E0%A4%A",
"/settings",
"/settings/displays",
"/account",
"/overview",
"/index.html",
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
"/settings/displays/add",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
@@ -107,13 +58,13 @@ describe("parseDashboardPath", () => {
});
describe("dashboardPath", () => {
it("spells each route canonically", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/modules/signage");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
});
it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/modules/a%2F..%2Fb");
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module",
moduleId: "a/../b",
@@ -126,7 +77,6 @@ describe("dashboardPath", () => {
{ kind: "module", moduleId: "example" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
expect(isDashboardPagePath(dashboardPath(route))).toBe(true);
}
});
});
+1 -1
View File
@@ -398,7 +398,7 @@ describe("display host — full-viewport boundary", () => {
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/modules/announcements")}
history={createMemoryHostHistory("/announcements")}
importModule={importer}
/>,
),
+6 -6
View File
@@ -287,7 +287,7 @@ describe("Ekklesia shell — browser authentication", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/modules/example");
window.history.replaceState(null, "", "/example");
});
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined;
@@ -563,7 +563,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/modules/example");
window.history.replaceState(null, "", "/example");
});
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined;
@@ -938,7 +938,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async () => artifact("livestream", "2.0.0"),
"/modules/livestream",
"/livestream",
);
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
@@ -954,7 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
},
"/modules/video",
"/video",
);
await act(async () => {
@@ -1015,7 +1015,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/modules/example");
window.history.replaceState(null, "", "/example");
});
it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
@@ -1188,7 +1188,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/modules/example");
window.history.replaceState(null, "", "/example");
});
it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
+21 -21
View File
@@ -14,21 +14,21 @@ describe("the browser host history", () => {
const history = createBrowserHostHistory(window);
const lengthBefore = window.history.length;
history.push("/modules/example");
history.push("/example");
expect(history.pathname()).toBe("/modules/example");
expect(window.location.pathname).toBe("/modules/example");
expect(history.pathname()).toBe("/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(lengthBefore + 1);
});
it("adds no entry when the page asked for is already open", () => {
const history = createBrowserHostHistory(window);
history.push("/modules/example");
history.push("/example");
const length = window.history.length;
const listener = vi.fn();
history.subscribe(listener);
history.push("/modules/example");
history.push("/example");
expect(window.history.length).toBe(length);
expect(listener).not.toHaveBeenCalled();
@@ -36,12 +36,12 @@ describe("the browser host history", () => {
it("corrects the current entry in place with replace", () => {
const history = createBrowserHostHistory(window);
history.push("/modules/example/");
history.push("/example/");
const length = window.history.length;
history.replace("/modules/example");
history.replace("/example");
expect(window.location.pathname).toBe("/modules/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(length);
});
@@ -50,14 +50,14 @@ describe("the browser host history", () => {
const listener = vi.fn();
const unsubscribe = history.subscribe(listener);
history.push("/modules/a");
history.replace("/modules/b");
history.push("/a");
history.replace("/b");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(listener).toHaveBeenCalledTimes(3);
unsubscribe();
window.dispatchEvent(new PopStateEvent("popstate"));
history.push("/modules/c");
history.push("/c");
expect(listener).toHaveBeenCalledTimes(3);
});
@@ -92,28 +92,28 @@ describe("the browser host history", () => {
};
const history = createBrowserHostHistory(view);
history.push("/modules/a");
history.replace("/modules/b");
history.push("/a");
history.replace("/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/modules/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/modules/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/b");
});
});
describe("the in-memory host history", () => {
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
const history = createMemoryHostHistory("/");
history.push("/modules/a");
history.push("/modules/b");
history.push("/a");
history.push("/b");
history.go(-1);
expect(history.pathname()).toBe("/modules/a");
expect(history.pathname()).toBe("/a");
history.push("/modules/c");
expect(history.entries).toEqual(["/", "/modules/a", "/modules/c"]);
history.push("/c");
expect(history.entries).toEqual(["/", "/a", "/c"]);
history.go(-10);
expect(history.pathname()).toBe("/");
history.go(10);
expect(history.pathname()).toBe("/modules/c");
expect(history.pathname()).toBe("/c");
});
});
+28 -17
View File
@@ -166,7 +166,7 @@ describe("the Dashboard opens on the page its URL names", () => {
});
it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => {
const { imported, titles } = await render("/modules/calendar");
const { imported, titles } = await render("/calendar");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
@@ -177,14 +177,14 @@ describe("the Dashboard opens on the page its URL names", () => {
});
it("opens a module from inside a group exactly as one outside it", async () => {
const { imported } = await render("/modules/signage");
const { imported } = await render("/signage");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]);
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
});
it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => {
const { imported, titles } = await render("/modules/disabled-module");
const { imported, titles } = await render("/disabled-module");
expect(query("host-module-unavailable")).not.toBeNull();
expect(query("module-container")).toBeNull();
@@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => {
});
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays", "/modules/calendar/events", "/account"]) {
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
await act(async () => {
root.unmount();
});
@@ -211,16 +211,27 @@ describe("the Dashboard opens on the page its URL names", () => {
}
});
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/modules/calendar/");
it("addresses module pages at the top level, beside the platform's own pages, and never confuses the two", async () => {
// Core refuses a module whose id is a platform page, but even a navigation that somehow listed
// one could not turn `/settings` into a module page.
const { imported } = await render("/settings");
expect(history.entries).toEqual(["/modules/calendar"]);
expect(query("host-not-found")).not.toBeNull();
expect(imported).toEqual([]);
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
expect(container.innerHTML).not.toContain("/modules/");
});
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/calendar/");
expect(history.entries).toEqual(["/calendar"]);
expect(imported).toHaveLength(1);
});
it("opens the deep link it was given once the person signs in, with no redirect", async () => {
const history = createMemoryHostHistory("/modules/calendar");
const { imported } = await render("/modules/calendar", { authenticated: false, history });
const history = createMemoryHostHistory("/calendar");
const { imported } = await render("/calendar", { authenticated: false, history });
expect(query("host-login")).not.toBeNull();
const email = query("login-email") as HTMLInputElement;
@@ -240,7 +251,7 @@ describe("the Dashboard opens on the page its URL names", () => {
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(history.entries).toEqual(["/modules/calendar"]);
expect(history.entries).toEqual(["/calendar"]);
});
});
@@ -250,8 +261,8 @@ describe("moving between pages", () => {
for (const [testId, href] of [
["select-overview", "/"],
["select-signage", "/modules/signage"],
["select-calendar", "/modules/calendar"],
["select-signage", "/signage"],
["select-calendar", "/calendar"],
] as const) {
const link = query(testId);
expect(link?.tagName, testId).toBe("A");
@@ -266,7 +277,7 @@ describe("moving between pages", () => {
const event = await click(query("select-calendar"));
expect(event.defaultPrevented).toBe(true);
expect(history.entries).toEqual(["/", "/modules/calendar"]);
expect(history.entries).toEqual(["/", "/calendar"]);
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
@@ -288,11 +299,11 @@ describe("moving between pages", () => {
});
it("adds no entry when the open page's own link is clicked again", async () => {
const { history } = await render("/modules/calendar");
const { history } = await render("/calendar");
await click(query("select-calendar"));
expect(history.entries).toEqual(["/modules/calendar"]);
expect(history.entries).toEqual(["/calendar"]);
});
it("walks Back and Forward through exactly the pages that were open", async () => {
@@ -319,11 +330,11 @@ describe("moving between pages", () => {
});
it("returns to the Overview from an unavailable page through its own link", async () => {
const { history } = await render("/modules/missing");
const { history } = await render("/missing");
await click(query("host-go-to-overview"));
expect(history.entries).toEqual(["/modules/missing", "/"]);
expect(history.entries).toEqual(["/missing", "/"]);
expect(query("host-overview")).not.toBeNull();
});