From 9afe9376c4ac041d3b340ff995c08e447459c485 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 14 Oct 2026 08:11:02 -0500 Subject: [PATCH 1/2] feat(shell): give every Dashboard page a real URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dashboard had one address. The API served the page only at `/`, both reference proxies forwarded only `/`, and the open module lived in React state, so a refresh went back to the first module, nothing could be bookmarked, and Back left the Dashboard (DESIGN.md §2.7). ADR 0057 makes the address bar the source of truth: - `/` is a host-owned Overview, now the landing page; `/modules/` mounts that module. An id the server's navigation does not list shows "This page isn't available" and loads nothing; any other Dashboard address is a host-owned "Page not found". - `/modules`, `/settings`, and `/account` are reserved whole, from one list (`@ekklesia/shell/dashboard-routes`) that the shell's router and the API's page routes both read. The API serves the same frozen page, ETag, and security headers anywhere inside them; look-alikes and other methods stay the sanitized 404. - A small host-owned history replaces the selected-module state, built in the entry point so it stays the only file touching `window`. Navigation items are real links; modified clicks are left to the browser. Focus moves to the page after an in-place change, and the tab is named after the page. - Sign-in still happens in place with no return value (ADR 0034), so a deep link opens its own page after sign-in. - Both reference Caddyfiles forward the new paths; the deploy smoke now reloads a module's address through the real proxy and requires it to mount again. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- apps/api/src/dashboard-host-routes.ts | 37 +- apps/api/tests/dashboard-host-routes.test.ts | 73 ++++ deploy/Caddyfile.example | 8 +- deploy/Caddyfile.split.example | 10 +- deploy/README.md | 15 +- .../0026-minimal-ekklesia-module-ui-host.md | 2 + ...7-dashboard-navigation-and-flat-modules.md | 4 + .../0034-host-owned-browser-authentication.md | 4 + ...57-dashboard-urls-are-host-owned-routes.md | 181 +++++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 4 +- docs/development/api.md | 8 + scripts/lib/deploy-smoke-browser.mjs | 18 +- scripts/lib/deploy-smoke-client.mjs | 19 + surfaces/shell/package.json | 4 + surfaces/shell/scripts/build-browser.mjs | 8 +- surfaces/shell/src/browser/DashboardLink.tsx | 82 ++++ surfaces/shell/src/browser/HostApp.tsx | 239 ++++++++---- surfaces/shell/src/browser/HostPages.tsx | 66 ++++ surfaces/shell/src/browser/host-history.ts | 128 +++++++ surfaces/shell/src/browser/main.tsx | 17 +- surfaces/shell/src/dashboard-routes.ts | 88 +++++ surfaces/shell/tests/auth-recovery.test.tsx | 9 +- surfaces/shell/tests/dashboard-routes.test.ts | 144 ++++++++ surfaces/shell/tests/display-app.test.tsx | 11 +- surfaces/shell/tests/host-app.test.tsx | 68 +++- surfaces/shell/tests/host-history.test.ts | 119 ++++++ surfaces/shell/tests/host-routing.test.tsx | 349 ++++++++++++++++++ surfaces/shell/tsconfig.build.json | 1 + tests/deploy.test.ts | 23 +- 31 files changed, 1634 insertions(+), 120 deletions(-) create mode 100644 docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md create mode 100644 surfaces/shell/src/browser/DashboardLink.tsx create mode 100644 surfaces/shell/src/browser/HostPages.tsx create mode 100644 surfaces/shell/src/browser/host-history.ts create mode 100644 surfaces/shell/src/dashboard-routes.ts create mode 100644 surfaces/shell/tests/dashboard-routes.test.ts create mode 100644 surfaces/shell/tests/host-history.test.ts create mode 100644 surfaces/shell/tests/host-routing.test.tsx diff --git a/README.md b/README.md index aaef160..8f7014c 100644 --- a/README.md +++ b/README.md @@ -822,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i public Website remain separate future designs. **Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested -folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing, +folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing +(since added; see the Stage 1 paragraph below), and no module-management, settings, or storage UI — module enablement and configuration are an API and a CLI, not a screen, and object storage is deployment configuration. A production CSP and deployment for the built shells, general sidecar asset publication, React runtime sharing, the @@ -905,6 +906,17 @@ the module's responsibility and do not exist yet.** Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise. +Stage 1 begins with **real Dashboard URLs** +([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The +Dashboard opens on an **Overview** at `/`, and each module's page has its own address, +`/modules/`, so a refresh, a bookmark, and a shared link land on the page that was open +and Back and Forward walk the Dashboard's own pages. The API serves the same Dashboard page at `/` and +throughout the reserved `/modules`, `/settings`, and `/account` namespaces, and both reference proxies +forward them. A module that is not in the server's navigation — disabled, removed, or with no UI — +shows a host-owned *This page isn't available* rather than loading anything. A module's own +sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings +link came from are later Stage 1 work. + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/apps/api/src/dashboard-host-routes.ts b/apps/api/src/dashboard-host-routes.ts index ea1658d..4c81ba5 100644 --- a/apps/api/src/dashboard-host-routes.ts +++ b/apps/api/src/dashboard-host-routes.ts @@ -1,13 +1,15 @@ +import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes"; import { Hono } from "hono"; import { etagMatches } from "./conditional-request.js"; import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js"; /** - * The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API - * serves: `/` is the only page the Dashboard has — it has no client router, and every emailed - * verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never + * The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API + * serves: the page answers at `/` and inside the Dashboard's reserved namespaces, which the shell + * exports so this server and the shell's router read one list, and the script lives beside, never * inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under - * `/api/v1/modules/`. + * `/api/v1/modules/`, and every emailed verification or reset link still lands on `/` with + * a query (ADR 0038). */ export const DASHBOARD_HOST_PAGE_PATH = "/"; export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; @@ -89,25 +91,34 @@ function fixedAsset( * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its * default same-origin shape. * - * Two exact literal routes over one frozen snapshot: never a static directory, never a path or file - * name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`, - * the `.map`, a traversal spelling — has no route and falls through to the application's ordinary - * sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders - * the sign-in form until `/api/v1/principal` says otherwise. + * Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken + * from the request, and never the source map. The page answers at `/` and anywhere inside a reserved + * Dashboard namespace — `/settings/displays/add` as much as `/settings` — with the **same** bytes and + * the same validator, because the path selects a page inside the shell rather than a file here; that + * is what lets a refreshed or bookmarked deep link stay where it was. Any other path — `/index.html`, + * `/app.js`, the `.map`, a look-alike such as `/settings-old`, a traversal spelling — has no route + * and falls through to the application's ordinary sanitized `404`. Like the display host's code, + * these bytes grant no authority: the page renders the sign-in form until `/api/v1/principal` says + * otherwise. */ export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono { const snapshot = snapshotAssets(assets); const routes = new Hono(); - routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => + const page = (ifNoneMatch: string | undefined): Response => fixedAsset( - c.req.header("if-none-match"), + ifNoneMatch, snapshot.html, snapshot.htmlEtag, "text/html; charset=utf-8", DASHBOARD_PAGE_SECURITY_HEADERS, - ), - ); + ); + + routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match"))); + for (const prefix of DASHBOARD_PAGE_PREFIXES) { + routes.get(prefix, (c) => page(c.req.header("if-none-match"))); + routes.get(`${prefix}/*`, (c) => page(c.req.header("if-none-match"))); + } routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) => fixedAsset( diff --git a/apps/api/tests/dashboard-host-routes.test.ts b/apps/api/tests/dashboard-host-routes.test.ts index f41b749..91bde44 100644 --- a/apps/api/tests/dashboard-host-routes.test.ts +++ b/apps/api/tests/dashboard-host-routes.test.ts @@ -34,6 +34,7 @@ import { DASHBOARD_HOST_SCRIPT_PATH, DashboardHostConfigurationError, } from "../src/dashboard-host-routes.js"; +import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes"; import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; @@ -193,6 +194,78 @@ describe("GET / (the Dashboard page)", () => { }); }); +describe("Dashboard deep links (ADR 0057)", () => { + const deepLinks = [ + "/modules", + "/modules/", + "/modules/signage", + "/modules/signage/timelines/a1b2", + "/modules/not-installed", + "/settings", + "/settings/displays/add", + "/account", + "/account/appearance", + ]; + + it("reserves exactly the namespaces the shell interprets, from the shell's own list", () => { + expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/settings", "/account"]); + }); + + it("serves the identical page, validator, and security headers anywhere inside them, so a refresh stays put", async () => { + const { app } = buildApp(); + + for (const path of deepLinks) { + const response = await app.request(path); + expect(response.status, path).toBe(200); + expect(await response.text(), path).toBe(PAGE_HTML); + expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8"); + expect(response.headers.get("cache-control"), path).toBe("no-cache"); + expect(response.headers.get("etag"), path).toBe(PAGE_ETAG); + expectPageSecurityHeaders(response); + } + expect(readFileMock).not.toHaveBeenCalled(); + }); + + it("revalidates a deep link with the page's one validator", async () => { + const { app } = buildApp(); + + const response = await app.request("/settings/displays", { + headers: { "If-None-Match": PAGE_ETAG }, + }); + + expect(response.status).toBe(304); + expect(await response.text()).toBe(""); + expectPageSecurityHeaders(response); + }); + + it("serves the page and never the script, whatever a deep link's last segment looks like", async () => { + const { app } = buildApp(); + + for (const path of [ + "/modules/app.js", + "/settings/assets/ekklesia-dashboard/app.js", + "/account/index.html", + ]) { + const response = await app.request(path); + expect(await response.text(), path).toBe(PAGE_HTML); + } + }); + + it("claims no look-alike prefix and no other method", async () => { + const { app } = buildApp(); + + for (const path of ["/modulesx", "/settings-old", "/accounts", "/Modules/signage"]) { + await expectSanitizedNotFound(await app.request(path), path); + } + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + await expectSanitizedNotFound( + await app.request("/settings/displays", { method }), + `${method} /settings/displays`, + ); + } + }); +}); + describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => { it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => { const { app } = buildApp(); diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example index f25a23a..de9c70e 100644 --- a/deploy/Caddyfile.example +++ b/deploy/Caddyfile.example @@ -41,9 +41,11 @@ } {$EKKLESIA_HOSTNAME} { - # Everything the API serves: the Dashboard at /, the API, display pages and their service - # worker, and the Dashboard and display host scripts. Any other path is refused here. - @ekklesia path / /api/* /display/* /assets/* + # Everything the API serves: the Dashboard at / and at its own pages (/modules, /settings, and + # /account, each with everything below it, so a refreshed deep link stays put), the API, display + # pages and their service worker, and the Dashboard and display host scripts. Any other path is + # refused here. + @ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/* # Module realtime is Server-Sent Events: flush every event immediately. @realtime path /api/v1/module-realtime/* diff --git a/deploy/Caddyfile.split.example b/deploy/Caddyfile.split.example index 19196a3..90baeff 100644 --- a/deploy/Caddyfile.split.example +++ b/deploy/Caddyfile.split.example @@ -45,11 +45,13 @@ header_up -True-Client-IP } -# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land -# on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one -# home, the API hostname, and the Dashboard can never fall back to a same-origin API. +# The Dashboard: its page — at `/` and at every Dashboard page (/modules, /settings, and /account, +# each with everything below it), so a refreshed deep link stays put — and its one script, nothing +# else. Emailed verification and reset links land on `/` with a query. `/api/*` is refused here on +# purpose, so the session cookie has exactly one home, the API hostname, and the Dashboard can never +# fall back to a same-origin API. {$EKKLESIA_DASHBOARD_HOSTNAME} { - @dashboard path / /assets/ekklesia-dashboard/* + @dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/* handle @dashboard { reverse_proxy api:3000 { diff --git a/deploy/README.md b/deploy/README.md index fd20a63..7dfda87 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -241,6 +241,12 @@ Module storage is installation state: back it up with the database and the bucke `docker compose run --rm migrate node core/dist/cli/apply-migrations.js`. 5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`. +**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with +the new `Caddyfile.example` (or `Caddyfile.split.example`). The Dashboard's matcher gains `/modules`, +`/settings`, `/account` and everything below each; without them the Dashboard still opens at `/`, +but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the proxy after +editing it. + ### Rolling back Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when @@ -263,7 +269,7 @@ The same image serves both; only configuration differs. | Hostname | Serves | Refuses (`404`) | | --- | --- | --- | -| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` | +| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, the Dashboard's pages (`/modules`, `/settings`, `/account`, each with everything below it), and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` | | API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` | | Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | | @@ -339,7 +345,10 @@ networks: Then make your proxy do what `Caddyfile.example` does: -- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`; +- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`, + and the Dashboard's pages: `/modules`, `/settings`, and `/account`, each bare and with everything + below it (ADR 0057) — without them, refreshing any Dashboard page other than `/` is your proxy's + `404`; - **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; - stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events); @@ -405,7 +414,7 @@ deployment: | # | Item | Where it is met | | --- | --- | --- | -| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. | +| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, the Dashboard's pages (`/modules`, `/settings`, `/account` and below), `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. | | 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. | | 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. | | 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. | diff --git a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md index 0e88cbb..55e4e12 100644 --- a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md +++ b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md @@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time. and its only allowed build script, and a second toolchain buys HMR that a proof does not need. - **A client router.** Rejected: a selected-module state is sufficient, and host routing versus module routing is a design this pass should not settle by accident. + > **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard + > now has host-owned URL routing; module routing remains a separate, deferred decision. - **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs to the host, and adopting it here for a two-item selector would prejudge that design. - **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one diff --git a/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md b/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md index 48ec85e..1188825 100644 --- a/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md +++ b/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md @@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden. ## Deferred +> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side +> routing and deep links to a selected module, deferred below, have landed; the rest of this list is +> unchanged. + Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges, and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or diff --git a/docs/architecture/decisions/0034-host-owned-browser-authentication.md b/docs/architecture/decisions/0034-host-owned-browser-authentication.md index b6352fb..5815ff1 100644 --- a/docs/architecture/decisions/0034-host-owned-browser-authentication.md +++ b/docs/architecture/decisions/0034-host-owned-browser-authentication.md @@ -165,6 +165,10 @@ redirect target. A future client router may define an internal return-location contract, but it must be a separately validated, same-origin route decision. This pass does not create one speculatively. +> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard +> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at +> the current URL, which is now a real page address. + ### Cookie and origin policy does not broaden for the Dashboard The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax, diff --git a/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md b/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md new file mode 100644 index 0000000..c64120e --- /dev/null +++ b/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md @@ -0,0 +1,181 @@ +# 0057: Dashboard URLs are real, host-owned routes + +- Status: Accepted +- Date: 2026-10-14 +- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md), + [0027](0027-dashboard-navigation-and-flat-modules.md), + [0034](0034-host-owned-browser-authentication.md), + [0044](0044-self-hosted-reference-deployment.md), + [0045](0045-split-dashboard-and-api-origins.md) + +## Context + +The Dashboard had exactly one URL. `apps/api` served the page at `/` and nowhere else, both +reference proxies forwarded only `/` to it, and the shell kept "which module is open" in React state. +Opening a module never changed the address bar, so a refresh always returned to the first module, +nothing could be bookmarked or shared, and the browser's Back button left the Dashboard entirely. +ADR 0026 rejected a client router *for that pass* — "a selected-module state is sufficient" — and +ADR 0027 deferred client routing and deep links. + +The product contract now requires them. DESIGN.md §2.7 says the URL represents the current place in +the product: a refresh returns to the same page, deep links work, Back and Forward behave +predictably. §5 makes an **Overview** the Dashboard's landing page, and §12.6 says a disabled module's +routes stop behaving as active routes. Everything else Stage 1 builds — the sidebar, Settings, the +account area, platform Displays, a module's own pages in the sidebar, and returning to the workflow a +Settings deep link came from — needs stable addresses to hang from, so this is the first slice. + +A route that works in the shell but not on refresh is worse than no route, because it looks like it +works. Refresh is a server and proxy concern before it is a browser one: the API had to serve the +page at the new paths, and the proxies had to forward them. + +## Decision + +### The Dashboard owns a closed set of namespaces + +```text +/ the Overview +/modules/ a module's page +/settings[/…] reserved for Settings +/account[/…] reserved for the account area +``` + +`DASHBOARD_PAGE_PREFIXES` (`surfaces/shell/src/dashboard-routes.ts`, exported as +`@ekklesia/shell/dashboard-routes`) lists `/modules`, `/settings`, and `/account`. It is the one +list: the shell's router parses against it, and `apps/api` registers its page routes from it, so the +server cannot serve a path the shell does not interpret and the shell cannot link to a path the +server refuses. None of the three overlaps a namespace something else owns — `/api`, `/display`, or +`/assets` — and a test holds that. + +The namespaces are reserved whole, including `/settings` and `/account`, which have no pages yet. +Reserving them now means that adding a Settings or Account page never requires another server change +or another edit to every installation's proxy configuration, which is an operator's job and easy to +miss. + +Module pages live under a prefix rather than at the root (`/signage`) so that no module id can ever +collide with a platform namespace and no platform namespace added later can shadow an installed +module. Module ids are manifest-validated and Core-owned; reserving words in that grammar would be a +contract change for a cosmetic gain. The prefix is an internal identifier in ADR 0046's sense, not +branding. + +### The server serves one page, unchanged, at every Dashboard path + +`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at each prefix, and at +each `prefix/*`, with **the same frozen bytes, the same strong `ETag`, and the same security headers** +as before. The path selects a page inside the shell, never a file on the server: nothing from the +request is composed into a lookup, no directory is exposed, and the script and source map remain +reachable only at their own literal paths. A look-alike (`/settings-old`, `/modulesx`), any other +method, and every other path still fall through to the sanitized `404`. The page grants no authority +wherever it is served; it renders the sign-in form until `/api/v1/principal` says otherwise. + +An unknown page inside a reserved namespace is answered `200` with the page, and the shell renders its +own *Page not found*. That is the price of letting the shell, which owns page meaning, decide what +exists; the server knows nothing of modules' or Settings' pages and should not. + +Both reference proxies forward exactly these paths (each prefix bare and with `/*`). In the split +topology (ADR 0045) they belong to the Dashboard hostname; the API hostname still serves no Dashboard +page. The development server falls back to the page for any path with no file. + +### The shell's router is host-owned, small, and deliberately not a library + +The address bar is the source of truth for which page is open. The shell reads it through a +four-method `HostHistory` (`pathname`, `push`, `replace`, `subscribe`); the browser implementation is +built in the entry point from `window` and handed down, so the entry point remains the only file in +the package that touches a browser global, and every routing behaviour is tested against an in-memory +history whose entries a test can inspect. No state object is ever stored in a history entry — the path +alone is meaningful — so nothing put into history can be read back as authority. + +`react-router-dom` is not adopted. Modules are refused it because routing belongs to the host +(ADR 0022, `pnpm boundaries`); the shell's own pages are a handful of host screens plus one mounted +module; and adopting a library now would prejudge how a module's own pages are addressed, which is +a separate decision (see **Deferred**). + +### What each path shows + +- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is + the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to + show yet. +- `/modules/` mounts the module whose navigation entry has that id, through the unchanged generic + loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's + navigation is the only authority on what exists. An id it does not list — a disabled, blocked, or + removed module, one with no UI, or a typo — shows *This page isn't available* and loads nothing; + the page never echoes the address back. This is what makes a disabled module's routes stop behaving + as active routes, with no new server state. +- Everything else — including anything below `/modules//` and every `/settings` and `/account` + path for now — is *Page not found*, with a link to the Overview. The shell never guesses. +- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new + entry), so what is bookmarked next is the one canonical address. Module ids are percent-encoded + into exactly one segment. + +### Navigation is real links + +Every navigation item is an `` with its canonical address and `aria-current="page"` when it +is open, marked by weight as well as colour. An ordinary click opens the page in place and adds one +history entry; a click with a modifier key or the middle button is left to the browser, so a page can +be opened in a new tab. Clicking the open page's own link adds no entry. The browser's Back and Forward +walk exactly the pages that were open. + +### Page changes behave like page loads for assistive technology + +After an in-place page change the shell moves focus to the page's `
` (named after the page) as +a full load would have; the first page shown after a refresh or sign-in is left alone. The entry point +names the browser tab after the open page ("Overview", the module's display name, or the error page's +title, and "Sign in" while signed out) — never "Ekklesia", in keeping with ADR 0046. The static page's +own `` is unchanged here; it belongs to the branding work. + +### Sign-in is unchanged, and still has no return URL + +ADR 0034 holds exactly. The sign-in form still replaces the application in place at the current URL, +so a deep link opened while signed out lands on its own page once the person signs in — with no +`return`, `next`, or callback value, no redirect, and no script-driven navigation off the +Dashboard's own paths. The shell only ever moves between its own canonical paths. Emailed +verification and reset links still land on `/` with a query (ADR 0038). + +## Alternatives + +- **Hash routing (`/#/modules/signage`).** Rejected: it needs no server change, but every address is + then one page to the server, the proxy, and any analytics; fragments are not sent to the server, so a + later server-side decision about a path is impossible; and DESIGN.md asks for real URLs. +- **Module pages at the root (`/signage/…`).** Rejected: module ids would collide with platform + namespaces, and reserving words would be a manifest-grammar change for a shorter address. +- **A server catch-all that serves the page for any unknown `GET`.** Rejected: it would answer `200` + for every mistyped API or asset path a browser requests, and it reverses the ADR 0044 posture that + the API serves only paths it means to. +- **Listing only the pages that exist (`/`, `/modules/*`).** Rejected: every Settings or Account page + would then need a server change and every installation's proxy edited again. +- **`react-router-dom` or another routing library.** Rejected for now; see above. +- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page, + and an address that silently means "whatever happens to be first" is not a stable address. +- **Showing the module's page for any path below `/modules/<id>/`.** Rejected: a module's own pages + need a contract. Mounting the module's first page for an address it never defined would make a + later contract break bookmarks people had already made. + +## Consequences + +- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the + Dashboard's own pages. Proven at three levels: the shell against an in-memory history, `apps/api` + serving the page at every reserved namespace and refusing look-alikes, and the deploy smoke test + reloading a module's address through the real split-topology proxy in a real browser. +- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in + Signage now chooses it once from the navigation, and can bookmark its address. +- **Operators with their own proxy must forward the new paths.** An installation using the reference + Caddyfiles gets them by copying the new examples; one with its own proxy must add `/modules`, + `/settings`, `/account` and everything below each, or a refresh on those pages will be the proxy's + `404`. The deployment guide says so. +- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains + private; this is the same relationship `./dashboard-assets` already has. +- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the + Overview as well as modules. + +## Deferred + +Each of these needs its own decision; this ADR reserves addresses for them and decides none of them: + +- **A module's own pages** — how a module declares the pages that appear under it in the sidebar, how + it learns and changes its current sub-path through the module UI SDK, and what an address below + `/modules/<id>/` means. That is a manifest and SDK contract change and its own ADR. +- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and + comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain + a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page + contract rather than here. +- The Settings and Account pages themselves, the sidebar redesign, the mobile drawer, Overview cards, + breadcrumbs, and the installation's name in the browser tab. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index ca39271..3de77ca 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -65,3 +65,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted - [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted - [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted +- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 00fd694..b1af83a 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -343,7 +343,9 @@ permission the inventory does; organizing requires the new platform permission shell renders that arrangement and computes none of it. **Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no -Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment +Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview +landing page have since landed; see +[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md)). A production CSP and deployment for the built shell, general asset publication, and the standalone host remain future work. Phase 7B pass 1 later supplies the host-owned sign-in flow described below. Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model diff --git a/docs/development/api.md b/docs/development/api.md index 89e7b5c..102c0b1 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -259,6 +259,14 @@ redirect target and performs no post-login navigation, so external and protocol- have no redirect sink in this pass. See [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md). +Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL +is a real page address: the API serves the Dashboard page at `/` and anywhere under `/modules`, +`/settings`, and `/account` (each bare and with everything below it), with the same bytes, validator, +and headers as `/`. A deep link opened while signed out therefore shows the sign-in form at that +address and opens its own page once the person signs in — still with no return value and no +redirect. Any other path, including look-alikes such as `/settings-old`, remains the sanitized +`404`. + ### The four anonymous screens The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in, diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index 41e7d55..679ea26 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -187,7 +187,9 @@ check( ); // --- A module UI, imported from the API's origin ------------------------------------------------- -const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')"; +// The first module link — never the Overview, which is the Dashboard's own landing page (ADR 0057). +const moduleButton = + 'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')'; const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation"); if (hasModule) { const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`); @@ -224,6 +226,20 @@ if (hasModule) { streams.every((response) => response.status === 200), `every realtime stream it opened was accepted (${streams.length} opened)`, ); + + // A refresh stays on the module's own page: the proxy forwards the deep link, the API serves the + // Dashboard page there, and the shell mounts the same module again (ADR 0057). + const moduleUrl = await evaluate("location.href"); + check( + moduleUrl === `${dashboardOrigin}/modules/${moduleId}`, + "opening the module put its own address in the address bar", + ); + await navigate(moduleUrl); + const remounted = await waitFor( + `location.href === ${JSON.stringify(moduleUrl)} && ${selector(`select-${moduleId}`)}?.getAttribute("aria-current") === "page" && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`, + "the module UI to mount again after a refresh", + ); + check(remounted, `refreshing ${moduleUrl} reopens the ${moduleId} module`); } // A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a // module unmounting, a navigation) is not. diff --git a/scripts/lib/deploy-smoke-client.mjs b/scripts/lib/deploy-smoke-client.mjs index 2ff4ddd..891fed7 100644 --- a/scripts/lib/deploy-smoke-client.mjs +++ b/scripts/lib/deploy-smoke-client.mjs @@ -88,6 +88,21 @@ async function edgeChecks() { "the Dashboard page names the API's origin, rendered at run time (ADR 0045)", ); + // Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). + for (const deepLink of ["/modules/example", "/settings/displays/add", "/account"]) { + const deepPage = await fetch(`${dashboardOrigin}${deepLink}`); + check( + deepPage.status === 200 && + deepPage.headers.get("etag") === page.headers.get("etag") && + (await deepPage.text()) === html, + `GET ${deepLink} through the proxy is the same Dashboard page`, + ); + } + check( + await refusedByProxy(`${dashboardOrigin}/settings-old`), + "a look-alike of a Dashboard namespace is refused by the proxy", + ); + const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`); await script.arrayBuffer(); check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200"); @@ -152,6 +167,10 @@ async function splitRoutingChecks() { "the Dashboard hostname refuses /display/*", ); check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /"); + check( + await refusedByProxy(`${apiOrigin}/settings/displays`), + "the API hostname serves no Dashboard page either", + ); check( await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`), "the API hostname refuses the Dashboard's script", diff --git a/surfaces/shell/package.json b/surfaces/shell/package.json index 6f3640b..dd54175 100644 --- a/surfaces/shell/package.json +++ b/surfaces/shell/package.json @@ -16,6 +16,10 @@ "types": "./dist/dashboard-assets.d.ts", "import": "./dist/dashboard-assets.js" }, + "./dashboard-routes": { + "types": "./dist/dashboard-routes.d.ts", + "import": "./dist/dashboard-routes.js" + }, "./display-assets": { "types": "./dist/display-assets.d.ts", "import": "./dist/display-assets.js" diff --git a/surfaces/shell/scripts/build-browser.mjs b/surfaces/shell/scripts/build-browser.mjs index df2bf50..96edfc6 100644 --- a/surfaces/shell/scripts/build-browser.mjs +++ b/surfaces/shell/scripts/build-browser.mjs @@ -89,7 +89,13 @@ await copyFile( if (serve) { const context = await esbuild.context(buildOptions); await context.watch(); - const { host, port } = await context.serve({ servedir: outputDirectory }); + // Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as + // `/modules/example` reaches the shell's router here as it does from `apps/api` (ADR 0057). + // Development only: the API serves the page at exactly the Dashboard's reserved paths instead. + const { host, port } = await context.serve({ + fallback: path.join(outputDirectory, "index.html"), + servedir: outputDirectory, + }); console.log(`Ekklesia shell (development) on http://${host}:${port}`); console.log( apiBaseUrl === "" diff --git a/surfaces/shell/src/browser/DashboardLink.tsx b/surfaces/shell/src/browser/DashboardLink.tsx new file mode 100644 index 0000000..67489db --- /dev/null +++ b/surfaces/shell/src/browser/DashboardLink.tsx @@ -0,0 +1,82 @@ +import { designToken } from "@ekklesia/design-tokens"; +import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react"; +import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js"; +import type { HostHistory } from "./host-history.js"; + +export interface DashboardLinkProps { + readonly history: HostHistory; + readonly to: DashboardDestination; + readonly children: ReactNode; + /** Marks the link as the page currently open, for assistive technology and visually. */ + readonly current?: boolean; + readonly variant?: "navigation" | "action"; + readonly "data-testid"?: string; +} + +const variantClass = { + action: "ek-button ek-focusable ek-button--secondary", + navigation: "ek-button ek-focusable ek-button--quiet", +} as const; + +const navigationStyle: CSSProperties = { + justifyContent: "flex-start", + textDecoration: "none", + width: "100%", +}; + +/** The open page is shown by weight as well as colour, so it never depends on colour alone. */ +const currentStyle: CSSProperties = { + ...navigationStyle, + background: designToken("state.selected-background"), + color: designToken("state.selected-foreground"), + fontWeight: designToken("typography.weight-bold"), +}; + +/** A click the browser should handle itself: a new tab or window, a download, or another button. */ +function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean { + return ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ); +} + +/** + * A link to a Dashboard page, as a real `<a href>` (ADR 0057). + * + * A real link rather than a button, because it is one: it can be opened in a new tab, copied, and + * bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in + * place through the shell's history instead of reloading the document; every other click — with a + * modifier key, or the middle button — is left to the browser. + */ +export function DashboardLink(props: DashboardLinkProps): ReactElement { + const href = dashboardPath(props.to); + const variant = props.variant ?? "navigation"; + return ( + <a + aria-current={props.current ? "page" : undefined} + className={variantClass[variant]} + data-testid={props["data-testid"]} + href={href} + onClick={(event) => { + if (isBrowserHandledClick(event)) { + return; + } + event.preventDefault(); + props.history.push(href); + }} + style={ + variant === "action" + ? { textDecoration: "none" } + : props.current + ? currentStyle + : navigationStyle + } + > + {props.children} + </a> + ); +} diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index 5824d73..8686ef4 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -1,7 +1,15 @@ import { designToken } from "@ekklesia/design-tokens"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import { Button } from "@ekklesia/ui"; -import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; +import { + type ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { HostAuthError, type HostAuthLanding, @@ -12,6 +20,7 @@ import { signInWithEmail, signOutHost, } from "../auth-protocol.js"; +import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js"; import { bootstrapHost, type DashboardNavigationEntry, @@ -20,8 +29,11 @@ import { HostProtocolError, loadHostPrincipal, } from "../host-protocol.js"; +import { DashboardLink } from "./DashboardLink.js"; import { DevicePanel } from "./DevicePanel.js"; import { ForgotPasswordForm } from "./ForgotPasswordForm.js"; +import type { HostHistory } from "./host-history.js"; +import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js"; import { LoginForm } from "./LoginForm.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ResetPasswordForm } from "./ResetPasswordForm.js"; @@ -101,6 +113,15 @@ export interface HostAppProps { * so there is nothing an open redirect could use. */ readonly authLanding?: HostAuthLanding; + /** + * The address bar (ADR 0057). The current path decides which page is open, so a refresh, a + * bookmark, and Back and Forward all land where the person was. The entry point passes the + * browser's; tests pass an in-memory one. Must be a stable reference. + */ + readonly history: HostHistory; + /** Names the browser tab after the open page. Supplied by the entry point, which owns `document`; + * left unset, the shell names nothing. */ + readonly setDocumentTitle?: (title: string) => void; /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */ readonly importModule?: ModuleUiImporter; } @@ -167,6 +188,8 @@ const shellStyles = { main: { boxSizing: "border-box", minWidth: 0, + // A programmatic focus target after a page change (never in the tab order), not a control. + outline: "none", overflowX: "auto", padding: designToken("spacing.xl"), }, @@ -194,25 +217,30 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry ]; } -function ModuleButton(props: { +function ModuleLink(props: { readonly entry: DashboardNavigationEntry; - readonly selected: boolean; - readonly onSelect: () => void; + readonly history: HostHistory; + readonly route: DashboardRoute; }): ReactElement { + const moduleId = props.entry.descriptor.id; return ( - <Button - aria-current={props.selected ? "page" : undefined} - aria-pressed={props.selected} - data-testid={`select-${props.entry.descriptor.id}`} - onClick={props.onSelect} - style={{ justifyContent: "flex-start", width: "100%" }} - variant="quiet" + <DashboardLink + current={props.route.kind === "module" && props.route.moduleId === moduleId} + data-testid={`select-${moduleId}`} + history={props.history} + to={{ kind: "module", moduleId }} > {props.entry.name} - </Button> + </DashboardLink> ); } +/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is + * not a page change. */ +function routeKey(route: DashboardRoute): string { + return route.kind === "module" ? `module:${route.moduleId}` : route.kind; +} + function HostMessage(props: { readonly children: string; readonly testId: string; @@ -265,21 +293,27 @@ async function resolveBootstrapState( /** * The Ekklesia Dashboard shell and its host-owned authentication boundary. * - * The login form replaces the protected application in place. There is no redirect parameter, URL - * parser, location assignment, or client router in this shell, so the current internal Dashboard URL - * simply remains the current URL through sign-in and no external/protocol-relative return value can - * become a navigation sink. Modules receive the same five-field context after authentication; they - * neither render platform login nor receive sign-in/sign-out controls. + * The login form replaces the protected application in place. There is no redirect parameter, + * return value, or location assignment in this shell, so the current internal Dashboard URL simply + * remains the current URL through sign-in — a deep link opens on its own page once the person has + * signed in — and no external/protocol-relative value can become a navigation sink. The shell only + * ever moves between its own paths (ADR 0057). Modules receive the same context after + * authentication; they neither render platform login nor receive sign-in/sign-out controls. */ export function HostApp(props: HostAppProps): ReactElement { const [state, setState] = useState<BootstrapState>({ status: "loading" }); - const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined); const [bootstrapAttempt, setBootstrapAttempt] = useState(0); const activeTransitionRef = useRef<AbortController | undefined>(undefined); const revalidationRef = useRef<AbortController | undefined>(undefined); const generationRef = useRef(0); const mountedRef = useRef(true); - const { config, importModule } = props; + const mainRef = useRef<HTMLElement>(null); + const shownRouteRef = useRef<string | undefined>(undefined); + const { config, history, importModule, setDocumentTitle } = props; + const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]); + const readPathname = useCallback(() => history.pathname(), [history]); + const pathname = useSyncExternalStore(subscribe, readPathname, readPathname); + const route = useMemo(() => parseDashboardPath(pathname), [pathname]); // Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign // in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter // cannot resurrect a screen the person has left. @@ -306,14 +340,16 @@ export function HostApp(props: HostAppProps): ReactElement { const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => { setState(resolved); - if (resolved.status === "ready") { - const first = allEntries(resolved.bootstrap)[0]; - setSelectedKey(first ? moduleKey(first) : undefined); - } else { - setSelectedKey(undefined); - } }, []); + // A non-canonical spelling of a real page (`/modules/example/`) is corrected in place, adding no + // history entry, so what is bookmarked or shared next is the one canonical address. + useEffect(() => { + if (route.kind !== "not-found" && dashboardPath(route) !== pathname) { + history.replace(dashboardPath(route)); + } + }, [history, pathname, route]); + useEffect(() => { mountedRef.current = true; return () => { @@ -329,7 +365,6 @@ export function HostApp(props: HostAppProps): ReactElement { void bootstrapAttempt; const { controller, generation } = beginTransition(); setState({ status: "loading" }); - setSelectedKey(undefined); void resolveBootstrapState(config, controller.signal).then((resolved) => { if (!isCurrent(controller, generation)) { return; @@ -381,7 +416,6 @@ export function HostApp(props: HostAppProps): ReactElement { const { controller, generation } = beginTransition(); // The authenticated subtree disappears before the request starts. That synchronously schedules // ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions. - setSelectedKey(undefined); setState({ status: "signing-out" }); try { await signOutHost(config, controller.signal); @@ -435,7 +469,6 @@ export function HostApp(props: HostAppProps): ReactElement { const transition = beginTransition(); transition.controller.abort(); activeTransitionRef.current = undefined; - setSelectedKey(undefined); setAuthView({ kind: "sign-in" }); setState({ status: "anonymous" }); } @@ -451,7 +484,6 @@ export function HostApp(props: HostAppProps): ReactElement { const transition = beginTransition(); transition.controller.abort(); activeTransitionRef.current = undefined; - setSelectedKey(undefined); console.error("[ekklesia] session revalidation failed"); setState({ status: "failed" }); }) @@ -464,6 +496,41 @@ export function HostApp(props: HostAppProps): ReactElement { [beginTransition, config], ); + const openEntry = + state.status === "ready" && route.kind === "module" + ? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId) + : undefined; + const pageTitle = + state.status !== "ready" + ? undefined + : route.kind === "overview" + ? "Overview" + : route.kind === "module" + ? (openEntry?.name ?? "This page isn’t available") + : "Page not found"; + const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle; + + useEffect(() => { + if (documentTitle !== undefined) { + setDocumentTitle?.(documentTitle); + } + }, [documentTitle, setDocumentTitle]); + + // Moving to another page moves keyboard and screen-reader focus to the new page's content, as a + // full page load would have. The first page shown after sign-in or a refresh is left alone, and a + // canonical respelling of the same page is not a move. + const currentRouteKey = routeKey(route); + useEffect(() => { + if (state.status !== "ready") { + shownRouteRef.current = undefined; + return; + } + if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) { + mainRef.current?.focus(); + } + shownRouteRef.current = currentRouteKey; + }, [currentRouteKey, state.status]); + const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1); if (state.status === "loading") { @@ -553,7 +620,6 @@ export function HostApp(props: HostAppProps): ReactElement { const { bootstrap } = state; const entries = allEntries(bootstrap); - const selected = entries.find((entry) => moduleKey(entry) === selectedKey); const { groups, ungrouped } = bootstrap.navigation; return ( @@ -569,56 +635,71 @@ export function HostApp(props: HostAppProps): ReactElement { </header> <aside style={shellStyles.aside}> <DevicePanel config={config} /> - {entries.length === 0 && groups.length === 0 ? ( - <p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}> - No installed module provides a user interface. - </p> - ) : ( - <nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}> - {groups.map((group) => ( - <section data-testid={`group-${group.id}`} key={group.id}> - <h2 style={shellStyles.groupLabel}>{group.label}</h2> - <div style={{ display: "grid", gap: "0.125rem" }}> - {group.modules.map((entry) => ( - <ModuleButton - entry={entry} - key={moduleKey(entry)} - onSelect={() => setSelectedKey(moduleKey(entry))} - selected={moduleKey(entry) === selectedKey} - /> - ))} - </div> - </section> - ))} - {ungrouped.length > 0 ? ( - <section data-testid="group-ungrouped"> - {groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined} - <div style={{ display: "grid", gap: "0.125rem" }}> - {ungrouped.map((entry) => ( - <ModuleButton - entry={entry} - key={moduleKey(entry)} - onSelect={() => setSelectedKey(moduleKey(entry))} - selected={moduleKey(entry) === selectedKey} - /> - ))} - </div> - </section> - ) : undefined} - </nav> - )} + <nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}> + <DashboardLink + current={route.kind === "overview"} + data-testid="select-overview" + history={history} + to={{ kind: "overview" }} + > + Overview + </DashboardLink> + {entries.length === 0 && groups.length === 0 ? ( + <p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}> + No installed module provides a user interface. + </p> + ) : undefined} + {groups.map((group) => ( + <section data-testid={`group-${group.id}`} key={group.id}> + <h2 style={shellStyles.groupLabel}>{group.label}</h2> + <div style={{ display: "grid", gap: "0.125rem" }}> + {group.modules.map((entry) => ( + <ModuleLink + entry={entry} + history={history} + key={moduleKey(entry)} + route={route} + /> + ))} + </div> + </section> + ))} + {ungrouped.length > 0 ? ( + <section data-testid="group-ungrouped"> + {groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined} + <div style={{ display: "grid", gap: "0.125rem" }}> + {ungrouped.map((entry) => ( + <ModuleLink + entry={entry} + history={history} + key={moduleKey(entry)} + route={route} + /> + ))} + </div> + </section> + ) : undefined} + </nav> </aside> - <main style={shellStyles.main}> - {selected ? ( - <ModuleUiFrame - config={config} - descriptor={selected.descriptor} - {...(importModule ? { importModule } : {})} - key={moduleKey(selected)} - onAuthorizationResponse={onAuthorizationResponse} - principal={bootstrap.principal} - /> - ) : undefined} + <main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}> + {route.kind === "overview" ? ( + <OverviewPage /> + ) : route.kind === "module" ? ( + openEntry ? ( + <ModuleUiFrame + config={config} + descriptor={openEntry.descriptor} + {...(importModule ? { importModule } : {})} + key={moduleKey(openEntry)} + onAuthorizationResponse={onAuthorizationResponse} + principal={bootstrap.principal} + /> + ) : ( + <ModuleUnavailablePage history={history} /> + ) + ) : ( + <NotFoundPage history={history} /> + )} </main> </div> ); diff --git a/surfaces/shell/src/browser/HostPages.tsx b/surfaces/shell/src/browser/HostPages.tsx new file mode 100644 index 0000000..960bdaa --- /dev/null +++ b/surfaces/shell/src/browser/HostPages.tsx @@ -0,0 +1,66 @@ +import { EmptyState, PageHeader } from "@ekklesia/ui"; +import type { ReactElement } from "react"; +import { DashboardLink } from "./DashboardLink.js"; +import type { HostHistory } from "./host-history.js"; + +/** + * The Dashboard's landing page (DESIGN.md §5, ADR 0057). + * + * The Overview is where the Dashboard opens. Its cards will come from modules through a contract + * that does not exist yet, so for now it honestly says it is empty rather than inventing content. + */ +export function OverviewPage(): ReactElement { + return ( + <div data-testid="host-overview"> + <PageHeader title="Overview" /> + <EmptyState + description="Summaries from your modules will appear here as they become available. Choose a module in the navigation to open it." + title="Nothing to show yet" + /> + </div> + ); +} + +/** + * A module page whose module is not available to this Dashboard: it was disabled or removed, it + * provides no user interface, or the address was mistyped. The server's navigation is the only + * authority on what exists, and it does not say which of these applies, so neither does this page. + * It never echoes the address back. + */ +export function ModuleUnavailablePage(props: { readonly history: HostHistory }): ReactElement { + return ( + <div data-testid="host-module-unavailable"> + <PageHeader + actions={<OverviewAction history={props.history} />} + description="The module it belongs to may have been turned off or removed. If you expected to see it, ask an administrator." + title="This page isn’t available" + /> + </div> + ); +} + +/** Any other address inside the Dashboard. */ +export function NotFoundPage(props: { readonly history: HostHistory }): ReactElement { + return ( + <div data-testid="host-not-found"> + <PageHeader + actions={<OverviewAction history={props.history} />} + description="There is nothing at this address. Check it, or start again from the Overview." + title="Page not found" + /> + </div> + ); +} + +function OverviewAction(props: { readonly history: HostHistory }): ReactElement { + return ( + <DashboardLink + data-testid="host-go-to-overview" + history={props.history} + to={{ kind: "overview" }} + variant="action" + > + Go to Overview + </DashboardLink> + ); +} diff --git a/surfaces/shell/src/browser/host-history.ts b/surfaces/shell/src/browser/host-history.ts new file mode 100644 index 0000000..35b2a7e --- /dev/null +++ b/surfaces/shell/src/browser/host-history.ts @@ -0,0 +1,128 @@ +/** + * The shell's one view of the address bar (ADR 0057). + * + * The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the + * browser's Back and Forward buttons all have to land on the same page that was showing. Everything + * in the shell reads the current path and moves between pages through this small interface rather + * than through `window.history`, for two reasons. It keeps the entry point the only file in this + * package that touches a browser global — the browser implementation is handed the window's pieces, + * it does not reach for them. And it lets every routing behaviour be tested against an in-memory + * history whose entries a test can inspect. + * + * Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one + * mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to + * the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which + * a later decision owns. + */ +export interface HostHistory { + /** The current path, without query or fragment. */ + pathname(): string; + /** Opens a new page, adding a history entry. Opening the page already showing adds none. */ + push(path: string): void; + /** Corrects the current entry in place, adding none — for a non-canonical spelling of the same + * page. */ + replace(path: string): void; + /** Notifies after every change, whether made here or by the browser's Back and Forward. */ + subscribe(listener: () => void): () => void; +} + +/** The parts of a `Window` the browser history needs, passed in by the entry point. */ +export interface HostHistoryWindow { + readonly location: Pick<Location, "pathname">; + readonly history: Pick<History, "pushState" | "replaceState">; + addEventListener(type: "popstate", listener: () => void): void; + removeEventListener(type: "popstate", listener: () => void): void; +} + +/** + * The address bar itself. `pushState` and `replaceState` fire no event of their own, so this + * notifies its subscribers after each one; `popstate` covers Back and Forward. + */ +export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory { + const listeners = new Set<() => void>(); + const notify = (): void => { + for (const listener of [...listeners]) { + listener(); + } + }; + return { + pathname: () => view.location.pathname, + push(path) { + if (path === view.location.pathname) { + return; + } + view.history.pushState(null, "", path); + notify(); + }, + replace(path) { + view.history.replaceState(null, "", path); + notify(); + }, + subscribe(listener) { + if (listeners.size === 0) { + view.addEventListener("popstate", notify); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + view.removeEventListener("popstate", notify); + } + }; + }, + }; +} + +/** An in-memory history with the same semantics, whose entries a caller can inspect and step + * through. Used by tests; production always uses {@link createBrowserHostHistory}. */ +export interface MemoryHostHistory extends HostHistory { + readonly entries: readonly string[]; + readonly index: number; + /** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */ + go(delta: number): void; +} + +export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory { + const entries: string[] = [initialPath]; + let index = 0; + const listeners = new Set<() => void>(); + const notify = (): void => { + for (const listener of [...listeners]) { + listener(); + } + }; + return { + get entries() { + return [...entries]; + }, + get index() { + return index; + }, + pathname: () => entries[index] ?? "/", + push(path) { + if (path === entries[index]) { + return; + } + entries.splice(index + 1, entries.length, path); + index = entries.length - 1; + notify(); + }, + replace(path) { + entries[index] = path; + notify(); + }, + go(delta) { + const next = Math.min(Math.max(index + delta, 0), entries.length - 1); + if (next !== index) { + index = next; + notify(); + } + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/surfaces/shell/src/browser/main.tsx b/surfaces/shell/src/browser/main.tsx index be57da8..39365fe 100644 --- a/surfaces/shell/src/browser/main.tsx +++ b/surfaces/shell/src/browser/main.tsx @@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"; import { consumeAuthLanding } from "../auth-protocol.js"; import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js"; import { HostApp } from "./HostApp.js"; +import { createBrowserHostHistory } from "./host-history.js"; /** * The one place the shell touches a browser global. @@ -23,6 +24,13 @@ const apiBaseUrl = resolveApiOrigin( * `consumeAuthLanding` for why the token must not stay in the URL. */ const authLanding = consumeAuthLanding(window.location, window.history); +/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */ +const history = createBrowserHostHistory(window); + +const setDocumentTitle = (title: string): void => { + document.title = title; +}; + const container = document.getElementById("ekklesia-host"); if (!container) { throw new Error("The Ekklesia shell requires a #ekklesia-host element."); @@ -53,9 +61,14 @@ function renderHost(config: EkklesiaHostConfig | undefined) { ); } return authLanding ? ( - <HostApp authLanding={authLanding} config={config} /> + <HostApp + authLanding={authLanding} + config={config} + history={history} + setDocumentTitle={setDocumentTitle} + /> ) : ( - <HostApp config={config} /> + <HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} /> ); } diff --git a/surfaces/shell/src/dashboard-routes.ts b/surfaces/shell/src/dashboard-routes.ts new file mode 100644 index 0000000..977d66b --- /dev/null +++ b/surfaces/shell/src/dashboard-routes.ts @@ -0,0 +1,88 @@ +/** + * The Dashboard's URL space (ADR 0057): which paths are Dashboard pages, and what each one means. + * + * Framework-free and free of browser globals, because two programs read it. The shell parses the + * address bar with it, and `apps/api` serves the Dashboard page at exactly the namespaces it lists, + * so a refreshed or bookmarked deep link reaches the shell instead of a `404`. One list, imported by + * both, is what keeps the server from serving a path the shell cannot interpret, or the shell from + * linking to a path the server would refuse. + */ + +/** + * Every namespace the Dashboard owns besides `/` itself. Reserved whole, including paths no page uses + * yet, so that adding a Settings or Account page never needs another server or proxy change. + * + * None of these overlaps a namespace something else owns: `/api` (the API), `/display` (the display + * host), and `/assets` (both hosts' scripts). + */ +export const DASHBOARD_PAGE_PREFIXES = Object.freeze([ + "/modules", + "/settings", + "/account", +] as const); + +/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */ +export type DashboardRoute = + | { readonly kind: "overview" } + | { readonly kind: "module"; readonly moduleId: string } + | { readonly kind: "not-found" }; + +/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */ +export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>; + +/** Whether the Dashboard page is served at this path: the root, or anything inside a reserved + * namespace. Says nothing about whether a page exists there; the shell decides that. */ +export function isDashboardPagePath(pathname: string): boolean { + return ( + pathname === "/" || + DASHBOARD_PAGE_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ) + ); +} + +function decodeSegment(segment: string): string | undefined { + try { + const decoded = decodeURIComponent(segment); + return decoded.length > 0 ? decoded : undefined; + } catch { + return undefined; + } +} + +/** + * Reads a path into a route. + * + * `/modules/<id>` names a module's page. The module id is only decoded here, never validated against + * a pattern: the shell compares it with the navigation the server resolved, and an id that matches + * no entry is an unavailable module, not a malformed URL. Everything below `/modules/<id>/` belongs + * to that module's own pages, which need a contract that does not exist yet, so it is `not-found` + * for now rather than silently showing the module's first page. One trailing slash is tolerated, as + * a person typing a URL expects; the shell then replaces it with the canonical spelling. + */ +export function parseDashboardPath(pathname: string): DashboardRoute { + if (pathname === "/") { + return { kind: "overview" }; + } + const segments = pathname.split("/"); + if (segments[0] !== "") { + return { kind: "not-found" }; + } + const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1); + if (meaningful.length === 2 && meaningful[0] === "modules") { + const moduleId = decodeSegment(meaningful[1] ?? ""); + if (moduleId !== undefined) { + return { kind: "module", moduleId }; + } + } + return { kind: "not-found" }; +} + +/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its + * own path segment. */ +export function dashboardPath(route: DashboardDestination): string { + if (route.kind === "overview") { + return "/"; + } + return `/modules/${encodeURIComponent(route.moduleId)}`; +} diff --git a/surfaces/shell/tests/auth-recovery.test.tsx b/surfaces/shell/tests/auth-recovery.test.tsx index 0165e0a..6c21877 100644 --- a/surfaces/shell/tests/auth-recovery.test.tsx +++ b/surfaces/shell/tests/auth-recovery.test.tsx @@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js"; import { HostApp } from "../src/browser/HostApp.js"; +import { createMemoryHostHistory } from "../src/browser/host-history.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js"; /** @@ -94,7 +95,13 @@ function platform(stub: AuthStub = {}): Platform { async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> { await act(async () => { - root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />); + root.render( + <HostApp + config={config} + history={createMemoryHostHistory("/")} + {...(authLanding ? { authLanding } : {})} + />, + ); }); } diff --git a/surfaces/shell/tests/dashboard-routes.test.ts b/surfaces/shell/tests/dashboard-routes.test.ts new file mode 100644 index 0000000..4fd31de --- /dev/null +++ b/surfaces/shell/tests/dashboard-routes.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + DASHBOARD_PAGE_PREFIXES, + 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("parseDashboardPath", () => { + it("makes the root the Overview", () => { + 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("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", + }); + }); + + it("treats every other shape as not found rather than guessing", () => { + for (const pathname of [ + "", + "/modules", + "/modules/", + "/modules//", + "//modules/signage", + "/modules/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", + ]) { + expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" }); + } + }); +}); + +describe("dashboardPath", () => { + it("spells each route canonically", () => { + expect(dashboardPath({ kind: "overview" })).toBe("/"); + expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/modules/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(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({ + kind: "module", + moduleId: "a/../b", + }); + }); + + it("round-trips through the parser", () => { + for (const route of [ + { kind: "overview" } as const, + { kind: "module", moduleId: "example" } as const, + ]) { + expect(parseDashboardPath(dashboardPath(route))).toEqual(route); + expect(isDashboardPagePath(dashboardPath(route))).toBe(true); + } + }); +}); + +describe("the route locator is published for the API that serves the page", () => { + it("is a narrow package subpath, not the shell root barrel", async () => { + const manifest = JSON.parse( + await readFile(path.join(packageDirectory, "package.json"), "utf8"), + ) as { exports?: Record<string, unknown> }; + expect(manifest.exports?.["./dashboard-routes"]).toEqual({ + import: "./dist/dashboard-routes.js", + types: "./dist/dashboard-routes.d.ts", + }); + }); +}); diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index f5e956d..46d3d8d 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -3,6 +3,7 @@ import { act, Component, type ReactElement, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HostApp } from "../src/browser/HostApp.js"; +import { createMemoryHostHistory } from "../src/browser/host-history.js"; import { DisplayApp } from "../src/display/DisplayApp.js"; import type { EkklesiaHostConfig, @@ -393,7 +394,15 @@ describe("display host — full-viewport boundary", () => { }); }; - await act(async () => root.render(<HostApp config={config} importModule={importer} />)); + await act(async () => + root.render( + <HostApp + config={config} + history={createMemoryHostHistory("/modules/announcements")} + importModule={importer} + />, + ), + ); // The management artifact rendered inside its own isolated root, not the shell's own tree. expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain( "announcement management", diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx index bbe142d..a22dd8d 100644 --- a/surfaces/shell/tests/host-app.test.tsx +++ b/surfaces/shell/tests/host-app.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HostApp } from "../src/browser/HostApp.js"; +import { createBrowserHostHistory } from "../src/browser/host-history.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js"; import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js"; @@ -122,12 +123,27 @@ function artifact(id: string, version: string, options: ArtifactOptions = {}) { }; } +/** + * Renders the shell over the real (happy-dom) address bar. `path` opens that page first, exactly as + * a deep link or a refresh would (ADR 0057); left out, the shell opens wherever the location already + * is — `/`, the Overview, unless a test put something else there. + */ async function render( config: EkklesiaHostConfig, importModule?: (url: string) => Promise<unknown>, + path?: string, ): Promise<void> { + if (path !== undefined) { + window.history.replaceState(null, "", path); + } await act(async () => { - root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />); + root.render( + <HostApp + config={config} + history={createBrowserHostHistory(window)} + {...(importModule ? { importModule } : {})} + />, + ); }); } @@ -268,6 +284,11 @@ describe("Ekklesia shell — bootstrap", () => { }); 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"); + }); it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => { let releasePrincipal: (() => void) | undefined; const principalPending = new Promise<void>((resolve) => { @@ -539,6 +560,11 @@ describe("Ekklesia shell — browser authentication", () => { }); 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"); + }); it("unmounts module UI and closes realtime before completing server-side logout", async () => { let releaseSignOut: (() => void) | undefined; const signOutPending = new Promise<void>((resolve) => { @@ -856,10 +882,8 @@ describe("Ekklesia shell — Dashboard navigation", () => { const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent); expect(labels).toEqual(["Planning", "Media"]); - const buttons = [...container.querySelectorAll("nav button")].map( - (button) => button.textContent, - ); - expect(buttons).toEqual(["Calendar", "Livestream", "Video"]); + const links = [...container.querySelectorAll("nav section a")].map((link) => link.textContent); + expect(links).toEqual(["Calendar", "Livestream", "Video"]); }); it("renders ungrouped modules alongside groups, under a heading", async () => { @@ -897,14 +921,29 @@ describe("Ekklesia shell — Dashboard navigation", () => { expect(query("group-media")?.textContent).toBe("Media"); }); - it("selects the first module in the arranged order and marks it active", async () => { + it("opens on the Overview rather than choosing a module, whatever the arrangement", async () => { + const importModule = vi.fn(async () => artifact("video", "0.1.0")); await render( { apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, - async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"), + importModule, ); - expect(query("select-video")?.getAttribute("aria-current")).toBe("page"); - expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull(); + expect(query("select-overview")?.getAttribute("aria-current")).toBe("page"); + expect(query("select-video")?.getAttribute("aria-current")).toBeNull(); + expect(query("module-container")).toBeNull(); + expect(importModule).not.toHaveBeenCalled(); + }); + + it("marks the open module's link as the current page", async () => { + await render( + { apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, + async () => artifact("livestream", "2.0.0"), + "/modules/livestream", + ); + + expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page"); + expect(query("select-video")?.getAttribute("aria-current")).toBeNull(); + expect(query("select-overview")?.getAttribute("aria-current")).toBeNull(); }); it("mounts a module selected from inside a group, using the descriptor the group carried", async () => { @@ -915,6 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => { requested.push(url); return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0"); }, + "/modules/video", ); await act(async () => { @@ -972,6 +1012,11 @@ describe("Ekklesia shell — Dashboard navigation", () => { }); 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"); + }); it("keeps fixed shell tracks while module content changes size", async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0")); @@ -1140,6 +1185,11 @@ describe("Ekklesia shell — mount lifecycle", () => { }); 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"); + }); it("renders its own error when the artifact cannot be imported", async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => { throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing"); diff --git a/surfaces/shell/tests/host-history.test.ts b/surfaces/shell/tests/host-history.test.ts new file mode 100644 index 0000000..027dc07 --- /dev/null +++ b/surfaces/shell/tests/host-history.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBrowserHostHistory, createMemoryHostHistory } from "../src/browser/host-history.js"; + +describe("the browser host history", () => { + beforeEach(() => { + window.history.replaceState(null, "", "/"); + }); + + afterEach(() => { + window.history.replaceState(null, "", "/"); + }); + + it("reads the address bar's path and moves it with real history entries", () => { + const history = createBrowserHostHistory(window); + const lengthBefore = window.history.length; + + history.push("/modules/example"); + + expect(history.pathname()).toBe("/modules/example"); + expect(window.location.pathname).toBe("/modules/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"); + const length = window.history.length; + const listener = vi.fn(); + history.subscribe(listener); + + history.push("/modules/example"); + + expect(window.history.length).toBe(length); + expect(listener).not.toHaveBeenCalled(); + }); + + it("corrects the current entry in place with replace", () => { + const history = createBrowserHostHistory(window); + history.push("/modules/example/"); + const length = window.history.length; + + history.replace("/modules/example"); + + expect(window.location.pathname).toBe("/modules/example"); + expect(window.history.length).toBe(length); + }); + + it("notifies after its own changes and after the browser's Back and Forward", () => { + const history = createBrowserHostHistory(window); + const listener = vi.fn(); + const unsubscribe = history.subscribe(listener); + + history.push("/modules/a"); + history.replace("/modules/b"); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(listener).toHaveBeenCalledTimes(3); + + unsubscribe(); + window.dispatchEvent(new PopStateEvent("popstate")); + history.push("/modules/c"); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it("listens to popstate only while something is subscribed", () => { + const view = { + addEventListener: vi.fn(), + history: { pushState: vi.fn(), replaceState: vi.fn() }, + location: { pathname: "/" }, + removeEventListener: vi.fn(), + }; + const history = createBrowserHostHistory(view); + + const first = history.subscribe(() => undefined); + const second = history.subscribe(() => undefined); + expect(view.addEventListener).toHaveBeenCalledTimes(1); + + first(); + expect(view.removeEventListener).not.toHaveBeenCalled(); + second(); + expect(view.removeEventListener).toHaveBeenCalledTimes(1); + expect(view.removeEventListener.mock.calls[0]?.[1]).toBe( + view.addEventListener.mock.calls[0]?.[1], + ); + }); + + it("stores no state object, so nothing a page put in history can be read back as authority", () => { + const view = { + addEventListener: vi.fn(), + history: { pushState: vi.fn(), replaceState: vi.fn() }, + location: { pathname: "/" }, + removeEventListener: vi.fn(), + }; + const history = createBrowserHostHistory(view); + + history.push("/modules/a"); + history.replace("/modules/b"); + + expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/modules/a"); + expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/modules/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.go(-1); + expect(history.pathname()).toBe("/modules/a"); + + history.push("/modules/c"); + expect(history.entries).toEqual(["/", "/modules/a", "/modules/c"]); + + history.go(-10); + expect(history.pathname()).toBe("/"); + history.go(10); + expect(history.pathname()).toBe("/modules/c"); + }); +}); diff --git a/surfaces/shell/tests/host-routing.test.tsx b/surfaces/shell/tests/host-routing.test.tsx new file mode 100644 index 0000000..ec472dd --- /dev/null +++ b/surfaces/shell/tests/host-routing.test.tsx @@ -0,0 +1,349 @@ +import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HostApp } from "../src/browser/HostApp.js"; +import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js"; +import type { EkklesiaHostConfig } from "../src/host-protocol.js"; +import { isolatedText } from "./support/isolated-dom.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +/** + * The Dashboard's URL is the source of truth for which page is open (ADR 0057). Every test drives + * the real shell through an in-memory history, so it can assert on the exact entries a person's + * Back and Forward buttons would walk. + */ + +const apiBaseUrl = "https://ekklesia.example.test"; + +let container: HTMLDivElement; +let root: Root; +let consoleError: ReturnType<typeof vi.spyOn>; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + consoleError.mockRestore(); +}); + +function entry(id: string, name: string, version = "0.1.0") { + return { + module: { + api: { baseUrl: `/api/v1/modules/${id}` }, + contributions: [], + display: null, + id, + permissions: [], + realtime: { baseUrl: `/api/v1/module-realtime/${id}` }, + ui: { url: `/api/v1/module-ui/${id}/${version}` }, + version, + }, + name, + }; +} + +const NAVIGATION = { + groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }], + ungrouped: [entry("calendar", "Calendar", "2.0.0")], +}; + +function platform(options: { authenticated?: boolean } = {}) { + let authenticated = options.authenticated ?? true; + const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/auth/sign-in/email")) { + void init; + authenticated = true; + return Response.json({ ok: true }); + } + if (url.endsWith("/api/v1/principal")) { + return Response.json( + authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" }, + ); + } + if (url.endsWith("/api/v1/dashboard/navigation")) { + return authenticated + ? Response.json(NAVIGATION) + : Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); + } + return Response.json({ code: "NOT_FOUND" }, { status: 404 }); + }) as unknown as typeof fetch; + return fetchImplementation; +} + +function artifactFor(url: string) { + const id = url.includes("/calendar/") ? "calendar" : "signage"; + return { + default: { + abi: MODULE_UI_ABI_VERSION, + id, + mount(element: Element, context: ModuleUiHostContext) { + element.textContent = `${context.moduleId} mounted`; + return () => { + element.textContent = ""; + }; + }, + version: id === "calendar" ? "2.0.0" : "0.1.0", + }, + }; +} + +interface Rendered { + readonly history: MemoryHostHistory; + readonly imported: string[]; + readonly titles: string[]; +} + +async function render( + path: string, + options: { authenticated?: boolean; history?: MemoryHostHistory } = {}, +): Promise<Rendered> { + const history = options.history ?? createMemoryHostHistory(path); + const imported: string[] = []; + const titles: string[] = []; + const config: EkklesiaHostConfig = { apiBaseUrl, fetch: platform(options) }; + await act(async () => { + root.render( + <HostApp + config={config} + history={history} + importModule={async (url) => { + imported.push(url); + return artifactFor(url); + }} + setDocumentTitle={(title) => titles.push(title)} + />, + ); + }); + return { history, imported, titles }; +} + +function query(testId: string): HTMLElement | null { + return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`); +} + +async function click(element: Element | null, init: MouseEventInit = {}): Promise<MouseEvent> { + if (!element) { + throw new Error("missing element"); + } + const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init }); + await act(async () => { + element.dispatchEvent(event); + }); + return event; +} + +async function go(history: MemoryHostHistory, delta: number): Promise<void> { + await act(async () => { + history.go(delta); + }); +} + +describe("the Dashboard opens on the page its URL names", () => { + it("lands on the Overview at the root, and mounts no module", async () => { + const { imported, titles } = await render("/"); + + expect(query("host-overview")).not.toBeNull(); + expect(query("host-overview")?.querySelector("h1")?.textContent).toBe("Overview"); + expect(query("module-container")).toBeNull(); + expect(imported).toEqual([]); + expect(query("select-overview")?.getAttribute("aria-current")).toBe("page"); + expect(titles.at(-1)).toBe("Overview"); + }); + + 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"); + + expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]); + expect(isolatedText(query("module-container"))).toBe("calendar mounted"); + expect(query("select-calendar")?.getAttribute("aria-current")).toBe("page"); + expect(query("select-overview")?.getAttribute("aria-current")).toBeNull(); + expect(query("host-overview")).toBeNull(); + expect(titles.at(-1)).toBe("Calendar"); + }); + + it("opens a module from inside a group exactly as one outside it", async () => { + const { imported } = await render("/modules/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"); + + expect(query("host-module-unavailable")).not.toBeNull(); + expect(query("module-container")).toBeNull(); + expect(imported).toEqual([]); + // The address is never echoed back into the page. + expect(container.textContent).not.toContain("disabled-module"); + expect(titles.at(-1)).toBe("This page isn’t available"); + expect(query("host-go-to-overview")?.getAttribute("href")).toBe("/"); + }); + + 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"]) { + await act(async () => { + root.unmount(); + }); + root = createRoot(container); + const { imported, titles } = await render(path); + + expect(query("host-not-found"), path).not.toBeNull(); + expect(imported, path).toEqual([]); + expect(titles.at(-1), path).toBe("Page not found"); + // Nothing in the navigation claims to be the open page. + expect(container.querySelector('[aria-current="page"]'), path).toBeNull(); + } + }); + + it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => { + const { history, imported } = await render("/modules/calendar/"); + + expect(history.entries).toEqual(["/modules/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 }); + expect(query("host-login")).not.toBeNull(); + + const email = query("login-email") as HTMLInputElement; + const password = query("login-password") as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + await act(async () => { + setter?.call(email, "owner@example.test"); + email.dispatchEvent(new Event("input", { bubbles: true })); + setter?.call(password, "correct password"); + password.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + query("host-login") + ?.querySelector("form") + ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + + 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"]); + }); +}); + +describe("moving between pages", () => { + it("renders navigation as real links to canonical addresses", async () => { + await render("/"); + + for (const [testId, href] of [ + ["select-overview", "/"], + ["select-signage", "/modules/signage"], + ["select-calendar", "/modules/calendar"], + ] as const) { + const link = query(testId); + expect(link?.tagName, testId).toBe("A"); + expect(link?.getAttribute("href"), testId).toBe(href); + } + expect(container.querySelector('nav[aria-label="Dashboard"]')).not.toBeNull(); + }); + + it("opens a page in place on an ordinary click, adding one history entry", async () => { + const { history, imported } = await render("/"); + + const event = await click(query("select-calendar")); + + expect(event.defaultPrevented).toBe(true); + expect(history.entries).toEqual(["/", "/modules/calendar"]); + expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]); + expect(isolatedText(query("module-container"))).toBe("calendar mounted"); + }); + + it("leaves a modified or middle click to the browser, so a page can open in a new tab", async () => { + const { history } = await render("/"); + + for (const init of [ + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + { altKey: true }, + { button: 1 }, + ]) { + const event = await click(query("select-calendar"), init); + expect(event.defaultPrevented, JSON.stringify(init)).toBe(false); + } + expect(history.entries).toEqual(["/"]); + }); + + it("adds no entry when the open page's own link is clicked again", async () => { + const { history } = await render("/modules/calendar"); + + await click(query("select-calendar")); + + expect(history.entries).toEqual(["/modules/calendar"]); + }); + + it("walks Back and Forward through exactly the pages that were open", async () => { + const { history, imported } = await render("/"); + await click(query("select-signage")); + await click(query("select-calendar")); + + await go(history, -1); + expect(isolatedText(query("module-container"))).toBe("signage mounted"); + expect(query("select-signage")?.getAttribute("aria-current")).toBe("page"); + + await go(history, -1); + expect(query("host-overview")).not.toBeNull(); + expect(query("module-container")).toBeNull(); + + await go(history, 1); + expect(isolatedText(query("module-container"))).toBe("signage mounted"); + expect(imported).toEqual([ + `${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`, + `${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`, + `${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`, + `${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`, + ]); + }); + + it("returns to the Overview from an unavailable page through its own link", async () => { + const { history } = await render("/modules/missing"); + + await click(query("host-go-to-overview")); + + expect(history.entries).toEqual(["/modules/missing", "/"]); + expect(query("host-overview")).not.toBeNull(); + }); + + it("moves focus to the new page's content after a page change, but not on first load", async () => { + await render("/"); + const main = container.querySelector("main"); + expect(main?.getAttribute("tabindex")).toBe("-1"); + expect(document.activeElement).not.toBe(main); + + await click(query("select-calendar")); + + expect(document.activeElement).toBe(main); + expect(main?.getAttribute("aria-label")).toBe("Calendar"); + }); + + it("names the browser tab after each page as it opens", async () => { + const { titles } = await render("/"); + await click(query("select-signage")); + await click(query("select-overview")); + + expect(titles).toEqual(["Overview", "Signage", "Overview"]); + }); +}); diff --git a/surfaces/shell/tsconfig.build.json b/surfaces/shell/tsconfig.build.json index ae7742e..85149be 100644 --- a/surfaces/shell/tsconfig.build.json +++ b/surfaces/shell/tsconfig.build.json @@ -11,6 +11,7 @@ "include": [ "src/index.ts", "src/dashboard-assets.ts", + "src/dashboard-routes.ts", "src/display-assets.ts", "src/host-protocol.ts", "src/module-context.ts" diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 64ed045..4a3f2fc 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -188,7 +188,12 @@ describe("reference deployment compose files", () => { .filter((line) => !line.trim().startsWith("#")) .join("\n"); - expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*"); + // The Dashboard's reserved namespaces (ADR 0057, `DASHBOARD_PAGE_PREFIXES` in + // `surfaces/shell/src/dashboard-routes.ts`), each both bare and with everything below it, so a + // refreshed deep link reaches the API instead of the proxy's 404. + expect(directives).toContain( + "@ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/*", + ); expect(directives).toContain("header_up X-Forwarded-For {client_ip}"); expect(directives).toContain("header_up -CF-Connecting-IP"); expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32"); @@ -200,6 +205,22 @@ describe("reference deployment compose files", () => { }); }); +describe("split-origin reference proxy", () => { + it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => { + const caddyfile = await text("Caddyfile.split.example"); + const directives = caddyfile + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .join("\n"); + + expect(directives).toContain( + "@dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/*", + ); + // The API hostname still serves no Dashboard page at all. + expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*"); + }); +}); + describe("reference deployment env examples", () => { it("contain no secret values", async () => { const secretKeys = new Map([ -- 2.54.0 From ba60f70835720817efebad46e7adbe1fcb70f744 Mon Sep 17 00:00:00 2001 From: ekklesia <ekklesia@ekklesisa.dev.local> Date: Wed, 14 Oct 2026 08:42:34 -0500 Subject: [PATCH 2/2] feat(shell): make module pages first-class top-level routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 <noreply@anthropic.com> --- README.md | 14 +- apps/api/src/app.ts | 16 +- apps/api/src/dashboard-host-routes.ts | 75 +++++-- apps/api/tests/dashboard-host-routes.test.ts | 207 +++++++++++++++--- core/src/modules/discovery.ts | 14 ++ core/src/modules/index.ts | 1 + core/src/modules/reserved-ids.ts | 33 +++ core/tests/module-discovery.test.ts | 48 ++++ deploy/Caddyfile.example | 21 +- deploy/Caddyfile.split.example | 26 ++- deploy/README.md | 21 +- ...57-dashboard-urls-are-host-owned-routes.md | 192 ++++++++++------ docs/development/api.md | 14 +- docs/module-development/README.md | 7 +- scripts/lib/deploy-smoke-browser.mjs | 2 +- scripts/lib/deploy-smoke-client.mjs | 37 +++- scripts/smoke-deploy.sh | 8 +- surfaces/shell/scripts/build-browser.mjs | 4 +- surfaces/shell/src/browser/HostApp.tsx | 2 +- surfaces/shell/src/dashboard-routes.ts | 75 +++---- surfaces/shell/tests/dashboard-routes.test.ts | 104 +++------ surfaces/shell/tests/display-app.test.tsx | 2 +- surfaces/shell/tests/host-app.test.tsx | 12 +- surfaces/shell/tests/host-history.test.ts | 42 ++-- surfaces/shell/tests/host-routing.test.tsx | 45 ++-- tests/deploy.test.ts | 57 ++++- 26 files changed, 745 insertions(+), 334 deletions(-) create mode 100644 core/src/modules/reserved-ids.ts diff --git a/README.md b/README.md index 8f7014c..aeb7528 100644 --- a/README.md +++ b/README.md @@ -908,12 +908,14 @@ module — Signage included — still do not exist, and nothing in Phase 8 claim Stage 1 begins with **real Dashboard URLs** ([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The -Dashboard opens on an **Overview** at `/`, and each module's page has its own address, -`/modules/<module-id>`, so a refresh, a bookmark, and a shared link land on the page that was open -and Back and Forward walk the Dashboard's own pages. The API serves the same Dashboard page at `/` and -throughout the reserved `/modules`, `/settings`, and `/account` namespaces, and both reference proxies -forward them. A module that is not in the server's navigation — disabled, removed, or with no UI — -shows a host-owned *This page isn't available* rather than loading anything. A module's own +Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own +address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark, +and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages. +The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout +each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves +the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and +discovery refuses a module that uses one. An installed module that is disabled shows a host-owned +*This page isn't available* rather than loading anything. A module's own sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings link came from are later Stage 1 work. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 674d773..9248840 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono { // own entirely. See `./health-routes.js`. app.route("/", createHealthRoutes(options.health)); - // The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They - // grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See - // `./dashboard-host-routes.js`. - app.route("/", createDashboardHostRoutes(options.dashboardHostAssets)); + // The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the + // page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at + // `/<module-id>`. They grant no authority and resolve no principal; the page asks + // `/api/v1/principal` itself. See `./dashboard-host-routes.js`. + app.route( + "/", + createDashboardHostRoutes(options.dashboardHostAssets, { + modulePages: options.moduleLifecycle.snapshot.discoveredModules + .filter((module) => module.entrypoints.ui !== undefined) + .map((module) => module.manifest.id), + }), + ); app.route( "/", diff --git a/apps/api/src/dashboard-host-routes.ts b/apps/api/src/dashboard-host-routes.ts index 4c81ba5..168f39e 100644 --- a/apps/api/src/dashboard-host-routes.ts +++ b/apps/api/src/dashboard-host-routes.ts @@ -1,15 +1,19 @@ -import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes"; +import { moduleIdSchema } from "@ekklesia/contracts"; +import { isReservedModuleId } from "@ekklesia/core/modules"; +import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes"; import { Hono } from "hono"; import { etagMatches } from "./conditional-request.js"; import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js"; /** * The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API - * serves: the page answers at `/` and inside the Dashboard's reserved namespaces, which the shell - * exports so this server and the shell's router read one list, and the script lives beside, never - * inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under - * `/api/v1/modules/<module-id>`, and every emailed verification or reset link still lands on `/` with - * a query (ADR 0038). + * serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so + * this server and the shell's router read one list), and at each installed module's `/<module-id>`. + * Every one of those first segments is either a reserved platform name or a module id Core accepted, + * and Core refuses a module id that is a reserved name, so no module page can shadow `/api`, + * `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display + * host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still + * lands on `/` with a query (ADR 0038). */ export const DASHBOARD_HOST_PAGE_PATH = "/"; export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; @@ -87,22 +91,57 @@ function fixedAsset( }); } +export interface DashboardHostRouteOptions { + /** + * The id of every module this process discovered that declares a Dashboard UI — enabled or not, + * since a disabled module's bookmark should open the Dashboard and say the module is unavailable + * rather than fail as an unknown address. Fixed at startup, like the rest of the module set. + */ + readonly modulePages: readonly string[]; +} + +/** Each module page must be a real module id that names no platform namespace, exactly once. + * Discovery already guarantees this; the routes refuse to compose rather than trust it. */ +function validatedModulePages(modulePages: readonly string[]): readonly string[] { + const seen = new Set<string>(); + for (const moduleId of modulePages) { + if ( + !moduleIdSchema.safeParse(moduleId).success || + isReservedModuleId(moduleId) || + DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) || + seen.has(moduleId) + ) { + throw new DashboardHostConfigurationError(); + } + seen.add(moduleId); + } + return [...seen]; +} + /** * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its * default same-origin shape. * * Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken - * from the request, and never the source map. The page answers at `/` and anywhere inside a reserved - * Dashboard namespace — `/settings/displays/add` as much as `/settings` — with the **same** bytes and - * the same validator, because the path selects a page inside the shell rather than a file here; that - * is what lets a refreshed or bookmarked deep link stay where it was. Any other path — `/index.html`, - * `/app.js`, the `.map`, a look-alike such as `/settings-old`, a traversal spelling — has no route - * and falls through to the application's ordinary sanitized `404`. Like the display host's code, - * these bytes grant no authority: the page renders the sign-in form until `/api/v1/principal` says - * otherwise. + * from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard + * platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed + * module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no + * server change later) — always with the **same** bytes and the same validator, because the path + * selects a page inside the shell rather than a file here; that is what lets a refreshed or + * bookmarked deep link stay where it was. + * + * A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not + * installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does + * `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the + * application's ordinary sanitized `404`. Like the display host's code, these bytes grant no + * authority: the page renders the sign-in form until `/api/v1/principal` says otherwise. */ -export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono { +export function createDashboardHostRoutes( + assets: DashboardHostAssets, + options: DashboardHostRouteOptions, +): Hono { const snapshot = snapshotAssets(assets); + const modulePages = validatedModulePages(options.modulePages); const routes = new Hono(); const page = (ifNoneMatch: string | undefined): Response => @@ -115,9 +154,9 @@ export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono { ); routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match"))); - for (const prefix of DASHBOARD_PAGE_PREFIXES) { - routes.get(prefix, (c) => page(c.req.header("if-none-match"))); - routes.get(`${prefix}/*`, (c) => page(c.req.header("if-none-match"))); + for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) { + routes.get(`/${name}`, (c) => page(c.req.header("if-none-match"))); + routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match"))); } routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) => diff --git a/apps/api/tests/dashboard-host-routes.test.ts b/apps/api/tests/dashboard-host-routes.test.ts index 91bde44..c597cd7 100644 --- a/apps/api/tests/dashboard-host-routes.test.ts +++ b/apps/api/tests/dashboard-host-routes.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { ANONYMOUS_PRINCIPAL, type ModuleRouteHandler, + parseModuleManifest, parsePermissionId, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; @@ -13,9 +14,13 @@ import { import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, + type DiscoveredModule, EMPTY_MODULE_REALTIME_REGISTRY, + isReservedModuleId, type LegacyModuleRuntimeServices, type ModuleDatabase, + type ModuleLifecycleConfiguration, + RESERVED_MODULE_IDS, } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; @@ -33,12 +38,23 @@ import { DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH, DashboardHostConfigurationError, + createDashboardHostRoutes, } from "../src/dashboard-host-routes.js"; -import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes"; -import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js"; +import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes"; +import { + MODULE_DISPLAY_DESCRIPTOR_PREFIX, + MODULE_DISPLAY_PAGE_PREFIX, +} from "../src/module-display-path.js"; +import { + DISPLAY_HOST_SCRIPT_PATH, + DISPLAY_SERVICE_WORKER_PATH, +} from "../src/module-display-routes.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; -import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; +import { + createInMemoryModuleLifecycleStore, + moduleLifecycleSnapshotFor, +} from "./support/in-memory-module-lifecycle.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { readyHealth } from "./support/ready-health.js"; @@ -79,9 +95,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices { return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); } +/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id + * and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */ +function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule { + const manifest = parseModuleManifest({ + capabilities: [], + compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" }, + entrypoints: { + server: "./dist/server/index.js", + ...(options.ui ? { ui: "./dist/ui/index.js" } : {}), + }, + id, + manifestVersion: 8, + name: `${id} module`, + permissions: [], + version: "0.1.0", + }); + return { + contributions: [], + directory: `/modules/${id}`, + entrypoints: { + server: `/modules/${id}/dist/server/index.js`, + ...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}), + }, + manifest, + manifestPath: `/modules/${id}/ekklesia.module.json`, + packageName: `@test/${id}`, + }; +} + function buildApp( options: { readonly assets?: DashboardHostAssets; + readonly configuration?: ModuleLifecycleConfiguration; + readonly discovered?: readonly DiscoveredModule[]; readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[]; } = {}, ) { @@ -101,7 +148,10 @@ function buildApp( moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: fakeModuleServices(), moduleUi: createModuleUiPublicationRegistry([]), - moduleLifecycle: emptyModuleLifecycle(), + moduleLifecycle: { + snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration), + store: createInMemoryModuleLifecycleStore(), + }, moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), @@ -195,24 +245,37 @@ describe("GET / (the Dashboard page)", () => { }); describe("Dashboard deep links (ADR 0057)", () => { + const installed = () => [ + discoveredModule("signage", { ui: true }), + discoveredModule("calendar", { ui: true }), + discoveredModule("retired", { ui: true }), + // A display-only module has no Dashboard page (ADR 0033). + discoveredModule("lobby-screen", { ui: false }), + ]; + // Installed but disabled: its address still opens the Dashboard, which says it is unavailable. + const configuration = { modules: [{ enabled: false, moduleId: "retired" }] }; + const deepLinks = [ - "/modules", - "/modules/", - "/modules/signage", - "/modules/signage/timelines/a1b2", - "/modules/not-installed", "/settings", + "/settings/", "/settings/displays/add", "/account", "/account/appearance", + "/signage", + "/signage/", + // Reserved for the module's own pages, which the shell will interpret (ADR 0057). + "/signage/timelines", + "/signage/timelines/a1b2", + "/calendar", + "/retired", ]; - it("reserves exactly the namespaces the shell interprets, from the shell's own list", () => { - expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/settings", "/account"]); + it("serves the shell's own platform pages, from the shell's own list", () => { + expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]); }); - it("serves the identical page, validator, and security headers anywhere inside them, so a refresh stays put", async () => { - const { app } = buildApp(); + it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => { + const { app } = buildApp({ configuration, discovered: installed() }); for (const path of deepLinks) { const response = await app.request(path); @@ -227,41 +290,119 @@ describe("Dashboard deep links (ADR 0057)", () => { }); it("revalidates a deep link with the page's one validator", async () => { - const { app } = buildApp(); + const { app } = buildApp({ discovered: installed() }); - const response = await app.request("/settings/displays", { - headers: { "If-None-Match": PAGE_ETAG }, - }); + for (const path of ["/settings/displays", "/signage"]) { + const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } }); + expect(response.status, path).toBe(304); + expect(await response.text(), path).toBe(""); + expectPageSecurityHeaders(response); + } + }); - expect(response.status).toBe(304); - expect(await response.text()).toBe(""); - expectPageSecurityHeaders(response); + it("never turns an arbitrary top-level path into a Dashboard page", async () => { + const { app } = buildApp({ configuration, discovered: installed() }); + + for (const path of [ + "/definitely-not-a-real-module", + "/definitely-not-a-real-module/timelines", + // Installed, but with no Dashboard UI. + "/lobby-screen", + // Look-alikes of real pages. + "/signages", + "/signage-old", + "/Signage", + "/settings-old", + "/accounts", + // The former implementation prefix is not a namespace at all. + "/modules", + "/modules/signage", + ]) { + await expectSanitizedNotFound(await app.request(path), path); + } + }); + + it("serves a module's page only while that module is installed", async () => { + const { app } = buildApp({ discovered: [] }); + + await expectSanitizedNotFound(await app.request("/signage"), "/signage"); + expect((await app.request("/settings")).status).toBe(200); }); it("serves the page and never the script, whatever a deep link's last segment looks like", async () => { - const { app } = buildApp(); + const { app } = buildApp({ discovered: installed() }); for (const path of [ - "/modules/app.js", + "/signage/app.js", "/settings/assets/ekklesia-dashboard/app.js", "/account/index.html", ]) { - const response = await app.request(path); - expect(await response.text(), path).toBe(PAGE_HTML); + expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML); } }); - it("claims no look-alike prefix and no other method", async () => { - const { app } = buildApp(); + it("answers no other method at a Dashboard page", async () => { + const { app } = buildApp({ discovered: installed() }); - for (const path of ["/modulesx", "/settings-old", "/accounts", "/Modules/signage"]) { - await expectSanitizedNotFound(await app.request(path), path); + for (const path of ["/settings/displays", "/signage"]) { + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`); + } } - for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { - await expectSanitizedNotFound( - await app.request("/settings/displays", { method }), - `${method} /settings/displays`, - ); + }); + + it("refuses at composition a module page that would shadow a platform namespace", () => { + for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) { + expect( + () => + createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }), + JSON.stringify(modulePages), + ).toThrow(DashboardHostConfigurationError); + } + }); +}); + +describe("the platform's reserved top-level names (ADR 0057)", () => { + it("cover every top-level namespace the API registers, so a module page can never shadow one", () => { + const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] }); + + const topLevel = new Set( + app.routes + .map((route) => route.path.split("/")[1] ?? "") + .filter((segment) => segment !== "" && segment !== "*"), + ); + topLevel.delete("signage"); + + // This composition publishes no display, so the display host's routes are absent here; they + // are checked through their own path constants below. + expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]); + for (const segment of topLevel) { + expect(isReservedModuleId(segment), segment).toBe(true); + } + }); + + it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => { + for (const path of [ + MODULE_DISPLAY_PAGE_PREFIX, + DISPLAY_SERVICE_WORKER_PATH, + DISPLAY_HOST_SCRIPT_PATH, + DASHBOARD_HOST_SCRIPT_PATH, + MODULE_DISPLAY_DESCRIPTOR_PREFIX, + ]) { + expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true); + } + expect([...RESERVED_MODULE_IDS].sort()).toEqual([ + "account", + "api", + "assets", + "display", + "settings", + ]); + }); + + it("include every Dashboard platform page the shell interprets", () => { + for (const page of DASHBOARD_PLATFORM_PAGES) { + expect(RESERVED_MODULE_IDS, page).toContain(page); } }); }); diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts index 139b306..6522721 100644 --- a/core/src/modules/discovery.ts +++ b/core/src/modules/discovery.ts @@ -9,6 +9,7 @@ import { safeParseModuleManifest, semanticVersionSchema, } from "@ekklesia/contracts"; +import { isReservedModuleId } from "./reserved-ids.js"; export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; @@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode = | "MODULE_ENTRYPOINT_INVALID" | "MODULE_ENTRYPOINT_MISSING" | "MODULE_ID_DUPLICATE" + | "MODULE_ID_RESERVED" | "MODULE_MANIFEST_INVALID" | "MODULE_MANIFEST_MISSING" | "MODULE_PACKAGE_INVALID" @@ -614,6 +616,18 @@ async function scanModuleDirectory( }); } + if (isReservedModuleId(manifest.id)) { + // A module's Dashboard page is `/<module-id>` (ADR 0057), in the same URL space as the + // platform's own top-level namespaces. + issues.push({ + code: "MODULE_ID_RESERVED", + message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`, + moduleDirectory: directoryName, + moduleId: manifest.id, + path: "id", + }); + } + const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); if (!artifacts) { return undefined; diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index 3dc29c2..0b01e18 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -13,6 +13,7 @@ export * from "./module-package-host.js"; export * from "./module-package-verification.js"; export * from "./permissions.js"; export * from "./realtime.js"; +export * from "./reserved-ids.js"; export * from "./runtime.js"; export * from "./schema-discovery.js"; export * from "./ui-publication.js"; diff --git a/core/src/modules/reserved-ids.ts b/core/src/modules/reserved-ids.ts new file mode 100644 index 0000000..724b3b2 --- /dev/null +++ b/core/src/modules/reserved-ids.ts @@ -0,0 +1,33 @@ +/** + * Top-level names the platform owns, which therefore can never be a module id (ADR 0057). + * + * Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own + * top-level namespaces in one URL space. A module called `settings` would collide with the + * Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the + * platform serves today, not a guess at what it might want later: + * + * - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts, + * display descriptors, and realtime (`/api/…`); + * - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`, + * `/assets/ekklesia-display/…`); + * - `display` — the display host's pages and service worker (`/display/…`); + * - `settings` and `account` — the Dashboard's own Settings and account areas. + * + * `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the + * shell interprets, so a new top-level platform route cannot be added without reserving it here. + * Reserving a name is a breaking change for any installation with a module of that id, so the set + * grows only deliberately. + */ +export const RESERVED_MODULE_IDS = Object.freeze([ + "account", + "api", + "assets", + "display", + "settings", +] as const); + +const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS); + +export function isReservedModuleId(moduleId: string): boolean { + return reserved.has(moduleId); +} diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index b68055d..b91d008 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -4,8 +4,10 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { discoverModules, + isReservedModuleId, ModuleDiscoveryError, type ModuleDiscoveryIssueCode, + RESERVED_MODULE_IDS, } from "../src/modules/index.js"; interface ModuleFixtureOptions { @@ -271,6 +273,52 @@ describe("module discovery", () => { ); }); + it("rejects a module whose id is one of the platform's reserved top-level names (ADR 0057)", async () => { + // Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a + // platform-owned top-level namespace would collide with the API, the display host, a host + // script, or a Dashboard page. + expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]); + expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true); + + for (const id of RESERVED_MODULE_IDS) { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, id); + + const error = await discoveryError(modulesDirectory); + + expect(error.issues, id).toEqual([ + expect.objectContaining({ + code: "MODULE_ID_RESERVED", + moduleDirectory: id, + moduleId: id, + path: "id", + }), + ]); + } + }); + + it("accepts ids that merely resemble a reserved name", async () => { + const modulesDirectory = await createModulesDirectory(); + for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) { + await createModule(modulesDirectory, id); + } + + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + expect(modules.map((module) => module.manifest.id).sort()).toEqual([ + "api-docs", + "displays", + "my-account", + "settings-sync", + ]); + expect(isReservedModuleId("settings")).toBe(true); + expect(isReservedModuleId("settings-sync")).toBe(false); + }); + it("enforces platform and Module SDK compatibility", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "future", { diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example index de9c70e..5b0db9e 100644 --- a/deploy/Caddyfile.example +++ b/deploy/Caddyfile.example @@ -41,11 +41,16 @@ } {$EKKLESIA_HOSTNAME} { - # Everything the API serves: the Dashboard at / and at its own pages (/modules, /settings, and - # /account, each with everything below it, so a refreshed deep link stays put), the API, display - # pages and their service worker, and the Dashboard and display host scripts. Any other path is - # refused here. - @ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/* + # Everything the API serves: the Dashboard at /, the API, display pages and their service + # worker, and the Dashboard and display host scripts. + @ekklesia path / /api/* /display/* /assets/* + + # The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed + # module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link + # stays put. Module ids are installation-specific, so this forwards every top-level name shaped + # like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere. + # Any other path is refused here. + @dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$ # Module realtime is Server-Sent Events: flush every event immediately. @realtime path /api/v1/module-realtime/* @@ -65,6 +70,12 @@ } } + handle @dashboardPage { + reverse_proxy api:3000 { + import ekklesia_forwarding + } + } + handle { respond 404 } diff --git a/deploy/Caddyfile.split.example b/deploy/Caddyfile.split.example index 90baeff..4983792 100644 --- a/deploy/Caddyfile.split.example +++ b/deploy/Caddyfile.split.example @@ -45,13 +45,21 @@ header_up -True-Client-IP } -# The Dashboard: its page — at `/` and at every Dashboard page (/modules, /settings, and /account, -# each with everything below it), so a refreshed deep link stays put — and its one script, nothing -# else. Emailed verification and reset links land on `/` with a query. `/api/*` is refused here on -# purpose, so the session cookie has exactly one home, the API hostname, and the Dashboard can never -# fall back to a same-origin API. +# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land +# on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one +# home, the API hostname, and the Dashboard can never fall back to a same-origin API. {$EKKLESIA_DASHBOARD_HOSTNAME} { - @dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/* + @dashboard path / /assets/ekklesia-dashboard/* + + # The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed + # module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link + # stays put. Module ids are installation-specific, so this forwards every top-level name shaped + # like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere. + # The API's own namespaces stay refused on this hostname. + @dashboardPage { + path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$ + not path /api /api/* /display /display/* /assets /assets/* + } handle @dashboard { reverse_proxy api:3000 { @@ -59,6 +67,12 @@ } } + handle @dashboardPage { + reverse_proxy api:3000 { + import ekklesia_forwarding + } + } + handle { respond 404 } diff --git a/deploy/README.md b/deploy/README.md index 7dfda87..778dcad 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -242,10 +242,12 @@ Module storage is installation state: back it up with the database and the bucke 5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`. **Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with -the new `Caddyfile.example` (or `Caddyfile.split.example`). The Dashboard's matcher gains `/modules`, -`/settings`, `/account` and everything below each; without them the Dashboard still opens at `/`, -but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the proxy after -editing it. +the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher +and `handle` block that forward every top-level path shaped like a module id — `/settings`, +`/account`, and each installed module's page such as `/signage` — to the API, which answers with +the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still +opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the +proxy after editing it. Installing or removing a module needs no proxy change. ### Rolling back @@ -269,7 +271,7 @@ The same image serves both; only configuration differs. | Hostname | Serves | Refuses (`404`) | | --- | --- | --- | -| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, the Dashboard's pages (`/modules`, `/settings`, `/account`, each with everything below it), and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` | +| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` | | API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` | | Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | | @@ -346,9 +348,10 @@ networks: Then make your proxy do what `Caddyfile.example` does: - forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`, - and the Dashboard's pages: `/modules`, `/settings`, and `/account`, each bare and with everything - below it (ADR 0057) — without them, refreshing any Dashboard page other than `/` is your proxy's - `404`; + and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is + `/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers + anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page + other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed; - **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; - stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events); @@ -414,7 +417,7 @@ deployment: | # | Item | Where it is met | | --- | --- | --- | -| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, the Dashboard's pages (`/modules`, `/settings`, `/account` and below), `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. | +| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. | | 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. | | 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. | | 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. | diff --git a/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md b/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md index c64120e..bf50f61 100644 --- a/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md +++ b/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md @@ -30,50 +30,91 @@ page at the new paths, and the proxies had to forward them. ## Decision -### The Dashboard owns a closed set of namespaces +### One top-level URL space, shared by platform pages and module pages ```text -/ the Overview -/modules/<module-id> a module's page -/settings[/…] reserved for Settings -/account[/…] reserved for the account area +/ the Overview +/settings[/…] Settings +/account[/…] the account area +/<module-id> a module's page: /signage, /calendar, /people +/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines ``` -`DASHBOARD_PAGE_PREFIXES` (`surfaces/shell/src/dashboard-routes.ts`, exported as -`@ekklesia/shell/dashboard-routes`) lists `/modules`, `/settings`, and `/account`. It is the one -list: the shell's router parses against it, and `apps/api` registers its page routes from it, so the -server cannot serve a path the shell does not interpret and the shell cannot link to a path the -server refuses. None of the three overlaps a namespace something else owns — `/api`, `/display`, or -`/assets` — and a test holds that. +A module is a first-class top-level destination, addressed by its id, exactly as Settings and the +Account area are. "Module" is an implementation concept — how the platform packages a product — and +it does not appear in the addresses people see, bookmark, and share. A module id is lowercase +kebab-case and Core-validated, which is what makes it usable as a path segment with no further +encoding. -The namespaces are reserved whole, including `/settings` and `/account`, which have no pages yet. -Reserving them now means that adding a Settings or Account page never requires another server change -or another edit to every installation's proxy configuration, which is an operator's job and easy to -miss. +### Platform-owned top-level names are reserved, and Core enforces it -Module pages live under a prefix rather than at the root (`/signage`) so that no module id can ever -collide with a platform namespace and no platform namespace added later can shadow an installed -module. Module ids are manifest-validated and Core-owned; reserving words in that grammar would be a -contract change for a cosmetic gain. The prefix is an internal identifier in ADR 0046's sense, not -branding. +Sharing one namespace means a module must never be able to take a name the platform uses. +`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves +at the top level **today**, taken from what is actually registered rather than from guesses: -### The server serves one page, unchanged, at every Dashboard path +| Name | Owned by | +| --- | --- | +| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime | +| `assets` | the Dashboard's and display host's scripts | +| `display` | the display host's pages and service worker | +| `settings` | the Dashboard's Settings pages | +| `account` | the Dashboard's account area | -`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at each prefix, and at -each `prefix/*`, with **the same frozen bytes, the same strong `ETag`, and the same security headers** -as before. The path selects a page inside the shell, never a file on the server: nothing from the -request is composed into a lookup, no directory is exposed, and the script and source map remain -reachable only at their own literal paths. A look-alike (`/settings-old`, `/modulesx`), any other -method, and every other path still fall through to the sanitized `404`. The page grants no authority -wherever it is served; it renders the sign-in form until `/api/v1/principal` says otherwise. +Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API +startup, `pnpm modules:validate`, and Module Package verification — before a module is installed, +not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids. -An unknown page inside a reserved namespace is answered `200` with the page, and the shell renders its -own *Page not found*. That is the price of letting the shell, which owns page meaning, decide what -exists; the server knows nothing of modules' or Settings' pages and should not. +The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the +composed API registers is a reserved name or a module page, the display host's and Dashboard +script's path constants all start with a reserved name, and every Dashboard page the shell +interprets is reserved. Adding a new top-level platform route therefore fails a test until its name +is reserved. Reserving a name later is a **breaking change** for any installation with a module of +that id, so the set grows only deliberately, and only for a real route. -Both reference proxies forward exactly these paths (each prefix bare and with `/*`). In the split -topology (ADR 0045) they belong to the Dashboard hostname; the API hostname still serves no Dashboard -page. The development server falls back to the page for any path with no file. +The rule lives in Core's discovery rather than in the module manifest grammar +(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a +standalone host (ADR 0021) has no Settings page to protect. A module built against the current +public packages is unaffected unless it uses one of these five names. + +### The server serves one page, unchanged, at every real Dashboard address — and nowhere else + +`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and +`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as +`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at +`/<module-id>` and everything below it for **each module this process discovered that declares a +Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security +headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from +the request is composed into a lookup, no directory is exposed, and the script and source map remain +reachable only at their own literal paths. + +The module set is the boot snapshot's discovered inventory, fixed at startup like every other +module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a +module an administrator turned off still opens the Dashboard, which says the module is unavailable; +that is more useful than an address that suddenly looks like it never existed. A module that is not +installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page. + +Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`), +a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes +also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or +appears twice — discovery already guarantees all three, and the routes do not trust it. + +Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a +Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the +display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather +than any data, and the alternative — a page at every module-shaped root path — is exactly the +"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory +and every module route keep their authorization unchanged. + +### The proxies forward module-shaped names and let the API decide + +A proxy cannot know which modules an installation has, and a proxy configuration listing them would +have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path +whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API +answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`, +`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the +split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses +`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The +development server falls back to the page for any path with no file. ### The shell's router is host-owned, small, and deliberately not a library @@ -94,17 +135,19 @@ a separate decision (see **Deferred**). - `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to show yet. -- `/modules/<id>` mounts the module whose navigation entry has that id, through the unchanged generic +- `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's - navigation is the only authority on what exists. An id it does not list — a disabled, blocked, or - removed module, one with no UI, or a typo — shows *This page isn't available* and loads nothing; - the page never echoes the address back. This is what makes a disabled module's routes stop behaving - as active routes, with no new server state. -- Everything else — including anything below `/modules/<id>/` and every `/settings` and `/account` - path for now — is *Page not found*, with a link to the Overview. The shell never guesses. + navigation is the only authority on what is mountable. An id it does not list — a disabled or + blocked module whose address the server still serves — shows *This page isn't available* and loads + nothing; the page never echoes the address back. This is what makes a disabled module's routes stop + behaving as active routes, with no new server state. +- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They + have no content yet, so they show *Page not found* for now. +- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today + and shows *Page not found* in the shell until the module page contract exists. The shell never + guesses, and never shows a module's first page for an address the module never defined. - One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new - entry), so what is bookmarked next is the one canonical address. Module ids are percent-encoded - into exactly one segment. + entry), so what is bookmarked next is the one canonical address. ### Navigation is real links @@ -132,35 +175,45 @@ verification and reset links still land on `/` with a query (ADR 0038). ## Alternatives -- **Hash routing (`/#/modules/signage`).** Rejected: it needs no server change, but every address is - then one page to the server, the proxy, and any analytics; fragments are not sent to the server, so a - later server-side decision about a path is impossible; and DESIGN.md asks for real URLs. -- **Module pages at the root (`/signage/…`).** Rejected: module ids would collide with platform - namespaces, and reserving words would be a manifest-grammar change for a shorter address. -- **A server catch-all that serves the page for any unknown `GET`.** Rejected: it would answer `200` - for every mistyped API or asset path a browser requests, and it reverses the ADR 0044 posture that - the API serves only paths it means to. -- **Listing only the pages that exist (`/`, `/modules/*`).** Rejected: every Settings or Account page - would then need a server change and every installation's proxy edited again. +- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept + in every address people see, and makes a module a second-class destination beside Settings. The + collision it avoided is handled instead by reserving the platform's names in Core. +- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and + any analytics; fragments never reach the server, so no server-side decision about a path is + possible; and DESIGN.md asks for real URLs. +- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module` + would become a Dashboard page, and the server would answer `200` for every mistyped address. It + would remove the installed-module disclosure described above, at the cost of the server no longer + meaning anything by a `200`. +- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an + administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the + Dashboard explaining that the module is unavailable. +- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit. +- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved + name refuses a real module, so a name is reserved when the platform gets a route there and not + before. +- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's + URL space, not a module property, and would need a public-contract release for no extra safety, + since discovery already refuses the module before it can be installed. - **`react-router-dom` or another routing library.** Rejected for now; see above. - **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page, and an address that silently means "whatever happens to be first" is not a stable address. -- **Showing the module's page for any path below `/modules/<id>/`.** Rejected: a module's own pages - need a contract. Mounting the module's first page for an address it never defined would make a - later contract break bookmarks people had already made. ## Consequences - A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the - Dashboard's own pages. Proven at three levels: the shell against an in-memory history, `apps/api` - serving the page at every reserved namespace and refusing look-alikes, and the deploy smoke test - reloading a module's address through the real split-topology proxy in a real browser. + Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api` + serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere + else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown + names, a module's address staying served while the module is disabled and disappearing and + returning as it is removed and reinstalled, + and reloading a module's address in a real browser and requiring the same module to mount again. - **The Dashboard opens on the Overview, not on the first module.** A person who used to land in - Signage now chooses it once from the navigation, and can bookmark its address. -- **Operators with their own proxy must forward the new paths.** An installation using the reference - Caddyfiles gets them by copying the new examples; one with its own proxy must add `/modules`, - `/settings`, `/account` and everything below each, or a refresh on those pages will be the proxy's - `404`. The deployment guide says so. + Signage now chooses it once from the navigation, and can bookmark `/signage`. +- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module + is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed. +- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above), + or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so. - `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains private; this is the same relationship `./dashboard-assets` already has. - The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the @@ -168,11 +221,14 @@ verification and reset links still land on `/` with a query (ADR 0038). ## Deferred -Each of these needs its own decision; this ADR reserves addresses for them and decides none of them: +Each of these needs its own decision. This ADR reserves the addresses for them and decides none of +them: -- **A module's own pages** — how a module declares the pages that appear under it in the sidebar, how - it learns and changes its current sub-path through the module UI SDK, and what an address below - `/modules/<id>/` means. That is a manifest and SDK contract change and its own ADR. +- **A module's own pages** — how a module declares the pages that appear under it in the sidebar + (`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its + current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The + server already serves the page there and the proxies already forward it, so that contract is a + manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR. - **Return to origin** — how a module or a Settings page links into another part of the Dashboard and comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page diff --git a/docs/development/api.md b/docs/development/api.md index 102c0b1..fcf1efa 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -260,12 +260,14 @@ have no redirect sink in this pass. See [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md). Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL -is a real page address: the API serves the Dashboard page at `/` and anywhere under `/modules`, -`/settings`, and `/account` (each bare and with everything below it), with the same bytes, validator, -and headers as `/`. A deep link opened while signed out therefore shows the sign-in form at that -address and opens its own page once the person signs in — still with no return value and no -redirect. Any other path, including look-alikes such as `/settings-old`, remains the sanitized -`404`. +is a real page address: the API serves the Dashboard page at `/`, at `/settings` and `/account`, and +at `/<module-id>` for every discovered module that declares a Dashboard UI (enabled or not) — each +bare and with everything below it — with the same bytes, validator, and headers as `/`. A deep link +opened while signed out therefore shows the sign-in form at that address and opens its own page once +the person signs in — still with no return value and no redirect. Any other path — a module that is +not installed, a display-only module, a look-alike such as `/settings-old` — remains the sanitized +`404`. Module ids `account`, `api`, `assets`, `display`, and `settings` are reserved for the platform +and refused by discovery (`MODULE_ID_RESERVED`). ### The four anonymous screens diff --git a/docs/module-development/README.md b/docs/module-development/README.md index e6e39b4..f1ee630 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -535,7 +535,12 @@ without executing module code: } ``` -Module, capability, and permission identifiers are lowercase and namespaced. A provided capability +Module, capability, and permission identifiers are lowercase and namespaced. A module with a +Dashboard UI is addressed at `/<module-id>` — `/calendar` for this one +([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same +top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a +module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and +package verification): `account`, `api`, `assets`, `display`, and `settings`. A provided capability has its own exact semantic version because its public contract can evolve independently from the module release. Capability dependencies use semantic-version ranges. Optional means the provider may be absent; if it is installed, its capability version must be compatible. diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index 679ea26..c9ce2ab 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -231,7 +231,7 @@ if (hasModule) { // Dashboard page there, and the shell mounts the same module again (ADR 0057). const moduleUrl = await evaluate("location.href"); check( - moduleUrl === `${dashboardOrigin}/modules/${moduleId}`, + moduleUrl === `${dashboardOrigin}/${moduleId}`, "opening the module put its own address in the address bar", ); await navigate(moduleUrl); diff --git a/scripts/lib/deploy-smoke-client.mjs b/scripts/lib/deploy-smoke-client.mjs index 891fed7..13dccbc 100644 --- a/scripts/lib/deploy-smoke-client.mjs +++ b/scripts/lib/deploy-smoke-client.mjs @@ -88,8 +88,9 @@ async function edgeChecks() { "the Dashboard page names the API's origin, rendered at run time (ADR 0045)", ); - // Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). - for (const deepLink of ["/modules/example", "/settings/displays/add", "/account"]) { + // Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module + // is installed yet at this point; a module's own address is reloaded in a real browser later. + for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) { const deepPage = await fetch(`${dashboardOrigin}${deepLink}`); check( deepPage.status === 200 && @@ -98,10 +99,15 @@ async function edgeChecks() { `GET ${deepLink} through the proxy is the same Dashboard page`, ); } - check( - await refusedByProxy(`${dashboardOrigin}/settings-old`), - "a look-alike of a Dashboard namespace is refused by the proxy", - ); + // A top-level name shaped like a module id reaches the API, which knows the installed modules and + // answers anything that is not a Dashboard page with its sanitized 404 — never the page. + for (const unknown of ["/definitely-not-a-real-module", "/settings-old", "/modules/example"]) { + const response = await fetch(`${dashboardOrigin}${unknown}`); + check( + response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}', + `GET ${unknown} through the proxy is the API's sanitized 404, not a Dashboard page`, + ); + } const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`); await script.arrayBuffer(); @@ -510,6 +516,25 @@ async function moduleChecks() { check(installed, `the module inventory lists the \`${moduleId}\` module`); } + // The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a + // Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is + // not. A disabled module is not running, but its address still opens the Dashboard. + const pageExpectation = + optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served"); + const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`); + const modulePageBody = await modulePage.text(); + if (pageExpectation === "absent") { + check( + modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}', + `GET /${moduleId} is the sanitized 404 while the module is not installed`, + ); + } else { + check( + modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'), + `GET /${moduleId} through the proxy is the Dashboard page while the module is installed`, + ); + } + if (moduleId === "example") { const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers }); if (expectation === "absent") { diff --git a/scripts/smoke-deploy.sh b/scripts/smoke-deploy.sh index 7e311fc..7db170e 100755 --- a/scripts/smoke-deploy.sh +++ b/scripts/smoke-deploy.sh @@ -543,9 +543,13 @@ recreate_api() { fi } -# module_check <expectation>: "absent", or the exact JSON array of values `example` must return. +# module_check <expectation> [page]: "absent", or the exact JSON array of values `example` must +# return. `page` is whether its Dashboard address is served (ADR 0057): by default, exactly when the +# module is running; "served" for a module that is installed but disabled, whose address still opens +# the Dashboard so it can say the module is unavailable. module_check() { client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \ + -e SMOKE_MODULE_PAGE="${2:-}" \ -e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \ -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" } @@ -729,7 +733,7 @@ if [[ "$topology" == split ]]; then step "Disabling and re-enabling the module through the supported lifecycle command" restart_api "${compose[@]}" run --rm --no-deps migrate node \ core/dist/cli/set-module-enabled.js --disable --module "$module_id" - module_check absent || fail "the disabled module was still reported as running." + module_check absent served || fail "the disabled module was still reported as running." module_storage "test -d /modules/${module_id}" || fail "disabling the module removed its installed package." restart_api "${compose[@]}" run --rm --no-deps migrate node \ diff --git a/surfaces/shell/scripts/build-browser.mjs b/surfaces/shell/scripts/build-browser.mjs index 96edfc6..85a2bd7 100644 --- a/surfaces/shell/scripts/build-browser.mjs +++ b/surfaces/shell/scripts/build-browser.mjs @@ -90,8 +90,8 @@ if (serve) { const context = await esbuild.context(buildOptions); await context.watch(); // Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as - // `/modules/example` reaches the shell's router here as it does from `apps/api` (ADR 0057). - // Development only: the API serves the page at exactly the Dashboard's reserved paths instead. + // `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057). + // Development only: the API serves the page only at its platform pages and installed modules'. const { host, port } = await context.serve({ fallback: path.join(outputDirectory, "index.html"), servedir: outputDirectory, diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index 8686ef4..3981e47 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -342,7 +342,7 @@ export function HostApp(props: HostAppProps): ReactElement { setState(resolved); }, []); - // A non-canonical spelling of a real page (`/modules/example/`) is corrected in place, adding no + // A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no // history entry, so what is bookmarked or shared next is the one canonical address. useEffect(() => { if (route.kind !== "not-found" && dashboardPath(route) !== pathname) { diff --git a/surfaces/shell/src/dashboard-routes.ts b/surfaces/shell/src/dashboard-routes.ts index 977d66b..76bd49c 100644 --- a/surfaces/shell/src/dashboard-routes.ts +++ b/surfaces/shell/src/dashboard-routes.ts @@ -1,25 +1,31 @@ /** - * The Dashboard's URL space (ADR 0057): which paths are Dashboard pages, and what each one means. + * The Dashboard's URL space (ADR 0057): what each Dashboard path means. * * Framework-free and free of browser globals, because two programs read it. The shell parses the - * address bar with it, and `apps/api` serves the Dashboard page at exactly the namespaces it lists, - * so a refreshed or bookmarked deep link reaches the shell instead of a `404`. One list, imported by - * both, is what keeps the server from serving a path the shell cannot interpret, or the shell from - * linking to a path the server would refuse. + * address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a + * refreshed or bookmarked deep link reaches the shell instead of a `404`. + * + * Module pages and platform pages share one top-level namespace: + * + * ```text + * / Overview + * /settings[/…] Settings + * /account[/…] the account area + * /<module-id>[/…] a module's page, and later its own sub-pages + * ``` + * + * The server serves the page at `/<module-id>` only for a module that is actually installed with a + * Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS` + * in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary + * root path never becomes one. */ /** - * Every namespace the Dashboard owns besides `/` itself. Reserved whole, including paths no page uses - * yet, so that adding a Settings or Account page never needs another server or proxy change. - * - * None of these overlaps a namespace something else owns: `/api` (the API), `/display` (the display - * host), and `/assets` (both hosts' scripts). + * The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page + * uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a + * reserved module id in Core; `apps/api` tests hold the two lists together. */ -export const DASHBOARD_PAGE_PREFIXES = Object.freeze([ - "/modules", - "/settings", - "/account", -] as const); +export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const); /** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */ export type DashboardRoute = @@ -30,16 +36,7 @@ export type DashboardRoute = /** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */ export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>; -/** Whether the Dashboard page is served at this path: the root, or anything inside a reserved - * namespace. Says nothing about whether a page exists there; the shell decides that. */ -export function isDashboardPagePath(pathname: string): boolean { - return ( - pathname === "/" || - DASHBOARD_PAGE_PREFIXES.some( - (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), - ) - ); -} +const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES); function decodeSegment(segment: string): string | undefined { try { @@ -53,12 +50,15 @@ function decodeSegment(segment: string): string | undefined { /** * Reads a path into a route. * - * `/modules/<id>` names a module's page. The module id is only decoded here, never validated against - * a pattern: the shell compares it with the navigation the server resolved, and an id that matches - * no entry is an unavailable module, not a malformed URL. Everything below `/modules/<id>/` belongs - * to that module's own pages, which need a contract that does not exist yet, so it is `not-found` - * for now rather than silently showing the module's first page. One trailing slash is tolerated, as - * a person typing a URL expects; the shell then replaces it with the canonical spelling. + * A single top-level segment that is not a platform page names a module's page. The id is only + * decoded here, never validated against a pattern: the shell compares it with the navigation the + * server resolved, and an id that matches no entry is an unavailable module, not a malformed URL. + * + * Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which + * need a contract that does not exist yet, so it is `not-found` for now rather than silently showing + * the module's first page. Settings and Account pages are likewise `not-found` until they exist. One + * trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the + * canonical spelling. */ export function parseDashboardPath(pathname: string): DashboardRoute { if (pathname === "/") { @@ -69,11 +69,12 @@ export function parseDashboardPath(pathname: string): DashboardRoute { return { kind: "not-found" }; } const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1); - if (meaningful.length === 2 && meaningful[0] === "modules") { - const moduleId = decodeSegment(meaningful[1] ?? ""); - if (moduleId !== undefined) { - return { kind: "module", moduleId }; - } + const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]); + if (first === undefined || platformPages.has(first)) { + return { kind: "not-found" }; + } + if (meaningful.length === 1) { + return { kind: "module", moduleId: first }; } return { kind: "not-found" }; } @@ -84,5 +85,5 @@ export function dashboardPath(route: DashboardDestination): string { if (route.kind === "overview") { return "/"; } - return `/modules/${encodeURIComponent(route.moduleId)}`; + return `/${encodeURIComponent(route.moduleId)}`; } diff --git a/surfaces/shell/tests/dashboard-routes.test.ts b/surfaces/shell/tests/dashboard-routes.test.ts index 4fd31de..9edc18e 100644 --- a/surfaces/shell/tests/dashboard-routes.test.ts +++ b/surfaces/shell/tests/dashboard-routes.test.ts @@ -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); } }); }); diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index 46d3d8d..71f51b2 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -398,7 +398,7 @@ describe("display host — full-viewport boundary", () => { root.render( <HostApp config={config} - history={createMemoryHostHistory("/modules/announcements")} + history={createMemoryHostHistory("/announcements")} importModule={importer} />, ), diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx index a22dd8d..38dc0de 100644 --- a/surfaces/shell/tests/host-app.test.tsx +++ b/surfaces/shell/tests/host-app.test.tsx @@ -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 () => { diff --git a/surfaces/shell/tests/host-history.test.ts b/surfaces/shell/tests/host-history.test.ts index 027dc07..26f5c6c 100644 --- a/surfaces/shell/tests/host-history.test.ts +++ b/surfaces/shell/tests/host-history.test.ts @@ -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"); }); }); diff --git a/surfaces/shell/tests/host-routing.test.tsx b/surfaces/shell/tests/host-routing.test.tsx index ec472dd..310109d 100644 --- a/surfaces/shell/tests/host-routing.test.tsx +++ b/surfaces/shell/tests/host-routing.test.tsx @@ -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(); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 4a3f2fc..4d5f78a 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -188,12 +188,13 @@ describe("reference deployment compose files", () => { .filter((line) => !line.trim().startsWith("#")) .join("\n"); - // The Dashboard's reserved namespaces (ADR 0057, `DASHBOARD_PAGE_PREFIXES` in - // `surfaces/shell/src/dashboard-routes.ts`), each both bare and with everything below it, so a - // refreshed deep link reaches the API instead of the proxy's 404. + expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*"); + // The Dashboard's other pages (ADR 0057): every top-level name shaped like a module id, and + // nothing else, so the API — which knows the installed modules — decides page or 404. expect(directives).toContain( - "@ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/*", + `@dashboardPage path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`, ); + expect(directives).toMatch(/handle @dashboardPage \{\s*reverse_proxy api:3000 \{/u); expect(directives).toContain("header_up X-Forwarded-For {client_ip}"); expect(directives).toContain("header_up -CF-Connecting-IP"); expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32"); @@ -205,6 +206,46 @@ describe("reference deployment compose files", () => { }); }); +/** The proxies' Dashboard-page matcher: a module id's grammar (`@ekklesia/contracts`) as the first + * segment, then anything. Checked against real paths below so the regular expression itself is + * tested, not just its spelling. */ +const DASHBOARD_PAGE_PATTERN = "^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$"; + +describe("the proxies' Dashboard-page matcher", () => { + const matcher = new RegExp(DASHBOARD_PAGE_PATTERN, "u"); + + it("forwards every Dashboard page and module-shaped name", () => { + for (const path of [ + "/settings", + "/settings/displays/add", + "/account", + "/signage", + "/signage/", + "/signage/timelines", + "/sunday-production", + "/definitely-not-a-real-module", + ]) { + expect(matcher.test(path), path).toBe(true); + } + }); + + it("forwards nothing that could not be a module id", () => { + for (const path of [ + "/", + "/favicon.ico", + "/robots.txt", + "/.well-known/x", + "/Signage", + "/-x", + "/1x", + "//signage", + "/a--b", + ]) { + expect(matcher.test(path), path).toBe(false); + } + }); +}); + describe("split-origin reference proxy", () => { it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => { const caddyfile = await text("Caddyfile.split.example"); @@ -213,9 +254,11 @@ describe("split-origin reference proxy", () => { .filter((line) => !line.trim().startsWith("#")) .join("\n"); - expect(directives).toContain( - "@dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/*", - ); + expect(directives).toContain("@dashboard path / /assets/ekklesia-dashboard/*"); + expect(directives).toContain(`path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`); + // The API's own namespaces stay refused on the Dashboard hostname even though they are shaped + // like a module id. + expect(directives).toContain("not path /api /api/* /display /display/* /assets /assets/*"); // The API hostname still serves no Dashboard page at all. expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*"); }); -- 2.54.0