feat(shell): give every Dashboard page a real URL
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s

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/<id>`
  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 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 08:11:02 -05:00
co-authored by Claude Opus 5.5
parent 44430b5d59
commit 9afe9376c4
31 changed files with 1634 additions and 120 deletions
+13 -1
View File
@@ -822,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
public Website remain separate future designs. public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested **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 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 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 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 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. 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/<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
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 ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+23 -12
View File
@@ -1,13 +1,15 @@
import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes";
import { Hono } from "hono"; import { Hono } from "hono";
import { etagMatches } from "./conditional-request.js"; import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.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 * The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed * serves: the page answers at `/` and inside the Dashboard's reserved namespaces, which the shell
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never * 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 * inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
* `/api/v1/modules/<module-id>`. * `/api/v1/modules/<module-id>`, 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_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
@@ -89,26 +91,35 @@ function fixedAsset(
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape. * default same-origin shape.
* *
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file * Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`, * from the request, and never the source map. The page answers at `/` and anywhere inside a reserved
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary * Dashboard namespace — `/settings/displays/add` as much as `/settings` — with the **same** bytes and
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders * the same validator, because the path selects a page inside the shell rather than a file here; that
* the sign-in form until `/api/v1/principal` says otherwise. * 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 { export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
const snapshot = snapshotAssets(assets); const snapshot = snapshotAssets(assets);
const routes = new Hono(); const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => const page = (ifNoneMatch: string | undefined): Response =>
fixedAsset( fixedAsset(
c.req.header("if-none-match"), ifNoneMatch,
snapshot.html, snapshot.html,
snapshot.htmlEtag, snapshot.htmlEtag,
"text/html; charset=utf-8", "text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS, 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) => routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset( fixedAsset(
c.req.header("if-none-match"), c.req.header("if-none-match"),
@@ -34,6 +34,7 @@ import {
DASHBOARD_HOST_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError, DashboardHostConfigurationError,
} from "../src/dashboard-host-routes.js"; } 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 { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.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)", () => { 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 () => { it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp(); const { app } = buildApp();
+5 -3
View File
@@ -41,9 +41,11 @@
} }
{$EKKLESIA_HOSTNAME} { {$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service # Everything the API serves: the Dashboard at / and at its own pages (/modules, /settings, and
# worker, and the Dashboard and display host scripts. Any other path is refused here. # /account, each with everything below it, so a refreshed deep link stays put), the API, display
@ekklesia path / /api/* /display/* /assets/* # 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. # Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/* @realtime path /api/v1/module-realtime/*
+6 -4
View File
@@ -45,11 +45,13 @@
header_up -True-Client-IP header_up -True-Client-IP
} }
# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land # The Dashboard: its page — at `/` and at every Dashboard page (/modules, /settings, and /account,
# on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one # each with everything below it), so a refreshed deep link stays put — and its one script, nothing
# home, the API hostname, and the Dashboard can never fall back to a same-origin API. # 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} { {$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/* @dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/*
handle @dashboard { handle @dashboard {
reverse_proxy api:3000 { reverse_proxy api:3000 {
+12 -3
View File
@@ -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`. `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`. 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 ### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when 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`) | | 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 `/` | | API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | | | Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
@@ -339,7 +345,10 @@ networks:
Then make your proxy do what `Caddyfile.example` does: 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 - **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events); - stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
@@ -405,7 +414,7 @@ deployment:
| # | Item | Where it is met | | # | 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. | | 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. | | 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`. | | 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -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. 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 - **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. 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 - **`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. 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 - **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
## Deferred ## 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 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, administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
@@ -165,6 +165,10 @@ redirect target.
A future client router may define an internal return-location contract, but it must be a separately 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. 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 ### Cookie and origin policy does not broaden for the Dashboard
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax, The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
@@ -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/<module-id> 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/<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.
- 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 `<a href>` 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 `<main>` (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 `<title>` 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.
+1
View File
@@ -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 - [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 - [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 - [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
+3 -1
View File
@@ -343,7 +343,9 @@ permission the inventory does; organizing requires the new platform permission
shell renders that arrangement and computes none of it. 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 **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 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. 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 Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
+8
View File
@@ -259,6 +259,14 @@ redirect target and performs no post-login navigation, so external and protocol-
have no redirect sink in this pass. See have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md). [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 four anonymous screens
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in, The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
+17 -1
View File
@@ -187,7 +187,9 @@ check(
); );
// --- A module UI, imported from the API's origin ------------------------------------------------- // --- 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"); const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
if (hasModule) { if (hasModule) {
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`); const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
@@ -224,6 +226,20 @@ if (hasModule) {
streams.every((response) => response.status === 200), streams.every((response) => response.status === 200),
`every realtime stream it opened was accepted (${streams.length} opened)`, `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 // A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
// module unmounting, a navigation) is not. // module unmounting, a navigation) is not.
+19
View File
@@ -88,6 +88,21 @@ async function edgeChecks() {
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)", "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`); const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer(); await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200"); check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
@@ -152,6 +167,10 @@ async function splitRoutingChecks() {
"the Dashboard hostname refuses /display/*", "the Dashboard hostname refuses /display/*",
); );
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /"); 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( check(
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`), await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script", "the API hostname refuses the Dashboard's script",
+4
View File
@@ -16,6 +16,10 @@
"types": "./dist/dashboard-assets.d.ts", "types": "./dist/dashboard-assets.d.ts",
"import": "./dist/dashboard-assets.js" "import": "./dist/dashboard-assets.js"
}, },
"./dashboard-routes": {
"types": "./dist/dashboard-routes.d.ts",
"import": "./dist/dashboard-routes.js"
},
"./display-assets": { "./display-assets": {
"types": "./dist/display-assets.d.ts", "types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js" "import": "./dist/display-assets.js"
+7 -1
View File
@@ -89,7 +89,13 @@ await copyFile(
if (serve) { if (serve) {
const context = await esbuild.context(buildOptions); const context = await esbuild.context(buildOptions);
await context.watch(); 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(`Ekklesia shell (development) on http://${host}:${port}`);
console.log( console.log(
apiBaseUrl === "" apiBaseUrl === ""
@@ -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>
);
}
+125 -44
View File
@@ -1,7 +1,15 @@
import { designToken } from "@ekklesia/design-tokens"; import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { Button } from "@ekklesia/ui"; 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 { import {
HostAuthError, HostAuthError,
type HostAuthLanding, type HostAuthLanding,
@@ -12,6 +20,7 @@ import {
signInWithEmail, signInWithEmail,
signOutHost, signOutHost,
} from "../auth-protocol.js"; } from "../auth-protocol.js";
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
import { import {
bootstrapHost, bootstrapHost,
type DashboardNavigationEntry, type DashboardNavigationEntry,
@@ -20,8 +29,11 @@ import {
HostProtocolError, HostProtocolError,
loadHostPrincipal, loadHostPrincipal,
} from "../host-protocol.js"; } from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import { DevicePanel } from "./DevicePanel.js"; import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.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 { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js"; import { ResetPasswordForm } from "./ResetPasswordForm.js";
@@ -101,6 +113,15 @@ export interface HostAppProps {
* so there is nothing an open redirect could use. * so there is nothing an open redirect could use.
*/ */
readonly authLanding?: HostAuthLanding; 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. */ /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter; readonly importModule?: ModuleUiImporter;
} }
@@ -167,6 +188,8 @@ const shellStyles = {
main: { main: {
boxSizing: "border-box", boxSizing: "border-box",
minWidth: 0, minWidth: 0,
// A programmatic focus target after a page change (never in the tab order), not a control.
outline: "none",
overflowX: "auto", overflowX: "auto",
padding: designToken("spacing.xl"), padding: designToken("spacing.xl"),
}, },
@@ -194,25 +217,30 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
]; ];
} }
function ModuleButton(props: { function ModuleLink(props: {
readonly entry: DashboardNavigationEntry; readonly entry: DashboardNavigationEntry;
readonly selected: boolean; readonly history: HostHistory;
readonly onSelect: () => void; readonly route: DashboardRoute;
}): ReactElement { }): ReactElement {
const moduleId = props.entry.descriptor.id;
return ( return (
<Button <DashboardLink
aria-current={props.selected ? "page" : undefined} current={props.route.kind === "module" && props.route.moduleId === moduleId}
aria-pressed={props.selected} data-testid={`select-${moduleId}`}
data-testid={`select-${props.entry.descriptor.id}`} history={props.history}
onClick={props.onSelect} to={{ kind: "module", moduleId }}
style={{ justifyContent: "flex-start", width: "100%" }}
variant="quiet"
> >
{props.entry.name} {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: { function HostMessage(props: {
readonly children: string; readonly children: string;
readonly testId: string; readonly testId: string;
@@ -265,21 +293,27 @@ async function resolveBootstrapState(
/** /**
* The Ekklesia Dashboard shell and its host-owned authentication boundary. * 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 * The login form replaces the protected application in place. There is no redirect parameter,
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL * return value, or location assignment in this shell, so the current internal Dashboard URL simply
* simply remains the current URL through sign-in and no external/protocol-relative return value can * remains the current URL through sign-in — a deep link opens on its own page once the person has
* become a navigation sink. Modules receive the same five-field context after authentication; they * signed in — and no external/protocol-relative value can become a navigation sink. The shell only
* neither render platform login nor receive sign-in/sign-out controls. * 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 { export function HostApp(props: HostAppProps): ReactElement {
const [state, setState] = useState<BootstrapState>({ status: "loading" }); const [state, setState] = useState<BootstrapState>({ status: "loading" });
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
const [bootstrapAttempt, setBootstrapAttempt] = useState(0); const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
const activeTransitionRef = useRef<AbortController | undefined>(undefined); const activeTransitionRef = useRef<AbortController | undefined>(undefined);
const revalidationRef = useRef<AbortController | undefined>(undefined); const revalidationRef = useRef<AbortController | undefined>(undefined);
const generationRef = useRef(0); const generationRef = useRef(0);
const mountedRef = useRef(true); 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 // 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 // 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. // cannot resurrect a screen the person has left.
@@ -306,14 +340,16 @@ export function HostApp(props: HostAppProps): ReactElement {
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => { const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
setState(resolved); 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(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
return () => { return () => {
@@ -329,7 +365,6 @@ export function HostApp(props: HostAppProps): ReactElement {
void bootstrapAttempt; void bootstrapAttempt;
const { controller, generation } = beginTransition(); const { controller, generation } = beginTransition();
setState({ status: "loading" }); setState({ status: "loading" });
setSelectedKey(undefined);
void resolveBootstrapState(config, controller.signal).then((resolved) => { void resolveBootstrapState(config, controller.signal).then((resolved) => {
if (!isCurrent(controller, generation)) { if (!isCurrent(controller, generation)) {
return; return;
@@ -381,7 +416,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { controller, generation } = beginTransition(); const { controller, generation } = beginTransition();
// The authenticated subtree disappears before the request starts. That synchronously schedules // The authenticated subtree disappears before the request starts. That synchronously schedules
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions. // ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
setSelectedKey(undefined);
setState({ status: "signing-out" }); setState({ status: "signing-out" });
try { try {
await signOutHost(config, controller.signal); await signOutHost(config, controller.signal);
@@ -435,7 +469,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition(); const transition = beginTransition();
transition.controller.abort(); transition.controller.abort();
activeTransitionRef.current = undefined; activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setAuthView({ kind: "sign-in" }); setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" }); setState({ status: "anonymous" });
} }
@@ -451,7 +484,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition(); const transition = beginTransition();
transition.controller.abort(); transition.controller.abort();
activeTransitionRef.current = undefined; activeTransitionRef.current = undefined;
setSelectedKey(undefined);
console.error("[ekklesia] session revalidation failed"); console.error("[ekklesia] session revalidation failed");
setState({ status: "failed" }); setState({ status: "failed" });
}) })
@@ -464,6 +496,41 @@ export function HostApp(props: HostAppProps): ReactElement {
[beginTransition, config], [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); const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
if (state.status === "loading") { if (state.status === "loading") {
@@ -553,7 +620,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { bootstrap } = state; const { bootstrap } = state;
const entries = allEntries(bootstrap); const entries = allEntries(bootstrap);
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
const { groups, ungrouped } = bootstrap.navigation; const { groups, ungrouped } = bootstrap.navigation;
return ( return (
@@ -569,22 +635,30 @@ export function HostApp(props: HostAppProps): ReactElement {
</header> </header>
<aside style={shellStyles.aside}> <aside style={shellStyles.aside}>
<DevicePanel config={config} /> <DevicePanel config={config} />
<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 ? ( {entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}> <p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface. No installed module provides a user interface.
</p> </p>
) : ( ) : undefined}
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
{groups.map((group) => ( {groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}> <section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2> <h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}> <div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => ( {group.modules.map((entry) => (
<ModuleButton <ModuleLink
entry={entry} entry={entry}
history={history}
key={moduleKey(entry)} key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))} route={route}
selected={moduleKey(entry) === selectedKey}
/> />
))} ))}
</div> </div>
@@ -595,30 +669,37 @@ export function HostApp(props: HostAppProps): ReactElement {
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined} {groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}> <div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => ( {ungrouped.map((entry) => (
<ModuleButton <ModuleLink
entry={entry} entry={entry}
history={history}
key={moduleKey(entry)} key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))} route={route}
selected={moduleKey(entry) === selectedKey}
/> />
))} ))}
</div> </div>
</section> </section>
) : undefined} ) : undefined}
</nav> </nav>
)}
</aside> </aside>
<main style={shellStyles.main}> <main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
{selected ? ( {route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame <ModuleUiFrame
config={config} config={config}
descriptor={selected.descriptor} descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})} {...(importModule ? { importModule } : {})}
key={moduleKey(selected)} key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse} onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal} principal={bootstrap.principal}
/> />
) : undefined} ) : (
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
)}
</main> </main>
</div> </div>
); );
+66
View File
@@ -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>
);
}
+128
View File
@@ -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);
};
},
};
}
+15 -2
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js"; import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js"; import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js"; import { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js";
/** /**
* The one place the shell touches a browser global. * 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. */ * `consumeAuthLanding` for why the token must not stay in the URL. */
const authLanding = consumeAuthLanding(window.location, window.history); 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"); const container = document.getElementById("ekklesia-host");
if (!container) { if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element."); throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
@@ -53,9 +61,14 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
); );
} }
return authLanding ? ( 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} />
); );
} }
+88
View File
@@ -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)}`;
}
+8 -1
View File
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js"; import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.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> { async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => { await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />); root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/")}
{...(authLanding ? { authLanding } : {})}
/>,
);
}); });
} }
@@ -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",
});
});
});
+10 -1
View File
@@ -3,6 +3,7 @@ import { act, Component, type ReactElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import { DisplayApp } from "../src/display/DisplayApp.js"; import { DisplayApp } from "../src/display/DisplayApp.js";
import type { import type {
EkklesiaHostConfig, 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. // The management artifact rendered inside its own isolated root, not the shell's own tree.
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain( expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
"announcement management", "announcement management",
+59 -9
View File
@@ -3,6 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import { createBrowserHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.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( async function render(
config: EkklesiaHostConfig, config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>, importModule?: (url: string) => Promise<unknown>,
path?: string,
): Promise<void> { ): Promise<void> {
if (path !== undefined) {
window.history.replaceState(null, "", path);
}
await act(async () => { 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", () => { 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 () => { it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined; let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => { const principalPending = new Promise<void>((resolve) => {
@@ -539,6 +560,11 @@ describe("Ekklesia shell — browser authentication", () => {
}); });
describe("Ekklesia shell — logout and session expiration", () => { 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 () => { it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined; let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => { const signOutPending = new Promise<void>((resolve) => {
@@ -856,10 +882,8 @@ describe("Ekklesia shell — Dashboard navigation", () => {
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent); const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
expect(labels).toEqual(["Planning", "Media"]); expect(labels).toEqual(["Planning", "Media"]);
const buttons = [...container.querySelectorAll("nav button")].map( const links = [...container.querySelectorAll("nav section a")].map((link) => link.textContent);
(button) => button.textContent, expect(links).toEqual(["Calendar", "Livestream", "Video"]);
);
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
}); });
it("renders ungrouped modules alongside groups, under a heading", async () => { 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"); 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( await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, { 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-overview")?.getAttribute("aria-current")).toBe("page");
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull(); 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 () => { 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); requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0"); return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
}, },
"/modules/video",
); );
await act(async () => { await act(async () => {
@@ -972,6 +1012,11 @@ describe("Ekklesia shell — Dashboard navigation", () => {
}); });
describe("Ekklesia shell — mount lifecycle", () => { 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 () => { it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0")); 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", () => { 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 () => { it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing"); throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
+119
View File
@@ -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");
});
});
+349
View File
@@ -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, "[email protected]");
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"]);
});
});
+1
View File
@@ -11,6 +11,7 @@
"include": [ "include": [
"src/index.ts", "src/index.ts",
"src/dashboard-assets.ts", "src/dashboard-assets.ts",
"src/dashboard-routes.ts",
"src/display-assets.ts", "src/display-assets.ts",
"src/host-protocol.ts", "src/host-protocol.ts",
"src/module-context.ts" "src/module-context.ts"
+22 -1
View File
@@ -188,7 +188,12 @@ describe("reference deployment compose files", () => {
.filter((line) => !line.trim().startsWith("#")) .filter((line) => !line.trim().startsWith("#"))
.join("\n"); .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 X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP"); expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32"); 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", () => { describe("reference deployment env examples", () => {
it("contain no secret values", async () => { it("contain no secret values", async () => {
const secretKeys = new Map([ const secretKeys = new Map([