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
+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();
});