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
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:
co-authored by
Claude Opus 5.5
parent
44430b5d59
commit
9afe9376c4
@@ -822,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
|
||||
public Website remain separate future designs.
|
||||
|
||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
|
||||
(since added; see the Stage 1 paragraph below),
|
||||
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||||
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
||||
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||||
@@ -905,6 +906,17 @@ the module's responsibility and do not exist yet.**
|
||||
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||
|
||||
Stage 1 begins with **real Dashboard URLs**
|
||||
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
|
||||
Dashboard opens on an **Overview** at `/`, and each module's page has its own address,
|
||||
`/modules/<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
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes";
|
||||
import { Hono } from "hono";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
|
||||
/**
|
||||
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
|
||||
* serves: the page answers at `/` and inside the Dashboard's reserved namespaces, which the shell
|
||||
* exports so this server and the shell's router read one list, and the script lives beside, never
|
||||
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||
* `/api/v1/modules/<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_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||
@@ -89,25 +91,34 @@ function fixedAsset(
|
||||
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||
* default same-origin shape.
|
||||
*
|
||||
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
|
||||
* from the request, and never the source map. The page answers at `/` and anywhere inside a reserved
|
||||
* Dashboard namespace — `/settings/displays/add` as much as `/settings` — with the **same** bytes and
|
||||
* the same validator, because the path selects a page inside the shell rather than a file here; that
|
||||
* is what lets a refreshed or bookmarked deep link stay where it was. Any other path — `/index.html`,
|
||||
* `/app.js`, the `.map`, a look-alike such as `/settings-old`, a traversal spelling — has no route
|
||||
* and falls through to the application's ordinary sanitized `404`. Like the display host's code,
|
||||
* these bytes grant no authority: the page renders the sign-in form until `/api/v1/principal` says
|
||||
* otherwise.
|
||||
*/
|
||||
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||
const snapshot = snapshotAssets(assets);
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||
const page = (ifNoneMatch: string | undefined): Response =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
ifNoneMatch,
|
||||
snapshot.html,
|
||||
snapshot.htmlEtag,
|
||||
"text/html; charset=utf-8",
|
||||
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
|
||||
for (const prefix of DASHBOARD_PAGE_PREFIXES) {
|
||||
routes.get(prefix, (c) => page(c.req.header("if-none-match")));
|
||||
routes.get(`${prefix}/*`, (c) => page(c.req.header("if-none-match")));
|
||||
}
|
||||
|
||||
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||
fixedAsset(
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
DashboardHostConfigurationError,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes";
|
||||
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
@@ -193,6 +194,78 @@ describe("GET / (the Dashboard page)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard deep links (ADR 0057)", () => {
|
||||
const deepLinks = [
|
||||
"/modules",
|
||||
"/modules/",
|
||||
"/modules/signage",
|
||||
"/modules/signage/timelines/a1b2",
|
||||
"/modules/not-installed",
|
||||
"/settings",
|
||||
"/settings/displays/add",
|
||||
"/account",
|
||||
"/account/appearance",
|
||||
];
|
||||
|
||||
it("reserves exactly the namespaces the shell interprets, from the shell's own list", () => {
|
||||
expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/settings", "/account"]);
|
||||
});
|
||||
|
||||
it("serves the identical page, validator, and security headers anywhere inside them, so a refresh stays put", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of deepLinks) {
|
||||
const response = await app.request(path);
|
||||
expect(response.status, path).toBe(200);
|
||||
expect(await response.text(), path).toBe(PAGE_HTML);
|
||||
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("cache-control"), path).toBe("no-cache");
|
||||
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
|
||||
expectPageSecurityHeaders(response);
|
||||
}
|
||||
expect(readFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates a deep link with the page's one validator", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request("/settings/displays", {
|
||||
headers: { "If-None-Match": PAGE_ETAG },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(304);
|
||||
expect(await response.text()).toBe("");
|
||||
expectPageSecurityHeaders(response);
|
||||
});
|
||||
|
||||
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of [
|
||||
"/modules/app.js",
|
||||
"/settings/assets/ekklesia-dashboard/app.js",
|
||||
"/account/index.html",
|
||||
]) {
|
||||
const response = await app.request(path);
|
||||
expect(await response.text(), path).toBe(PAGE_HTML);
|
||||
}
|
||||
});
|
||||
|
||||
it("claims no look-alike prefix and no other method", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of ["/modulesx", "/settings-old", "/accounts", "/Modules/signage"]) {
|
||||
await expectSanitizedNotFound(await app.request(path), path);
|
||||
}
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
await expectSanitizedNotFound(
|
||||
await app.request("/settings/displays", { method }),
|
||||
`${method} /settings/displays`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
}
|
||||
|
||||
{$EKKLESIA_HOSTNAME} {
|
||||
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
||||
# worker, and the Dashboard and display host scripts. Any other path is refused here.
|
||||
@ekklesia path / /api/* /display/* /assets/*
|
||||
# Everything the API serves: the Dashboard at / and at its own pages (/modules, /settings, and
|
||||
# /account, each with everything below it, so a refreshed deep link stays put), the API, display
|
||||
# pages and their service worker, and the Dashboard and display host scripts. Any other path is
|
||||
# refused here.
|
||||
@ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/*
|
||||
|
||||
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||
@realtime path /api/v1/module-realtime/*
|
||||
|
||||
@@ -45,11 +45,13 @@
|
||||
header_up -True-Client-IP
|
||||
}
|
||||
|
||||
# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land
|
||||
# on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one
|
||||
# home, the API hostname, and the Dashboard can never fall back to a same-origin API.
|
||||
# The Dashboard: its page — at `/` and at every Dashboard page (/modules, /settings, and /account,
|
||||
# each with everything below it), so a refreshed deep link stays put — and its one script, nothing
|
||||
# else. Emailed verification and reset links land on `/` with a query. `/api/*` is refused here on
|
||||
# purpose, so the session cookie has exactly one home, the API hostname, and the Dashboard can never
|
||||
# fall back to a same-origin API.
|
||||
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
||||
@dashboard path / /assets/ekklesia-dashboard/*
|
||||
@dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/*
|
||||
|
||||
handle @dashboard {
|
||||
reverse_proxy api:3000 {
|
||||
|
||||
+12
-3
@@ -241,6 +241,12 @@ Module storage is installation state: back it up with the database and the bucke
|
||||
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
||||
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||
|
||||
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
|
||||
the new `Caddyfile.example` (or `Caddyfile.split.example`). The Dashboard's matcher gains `/modules`,
|
||||
`/settings`, `/account` and everything below each; without them the Dashboard still opens at `/`,
|
||||
but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the proxy after
|
||||
editing it.
|
||||
|
||||
### Rolling back
|
||||
|
||||
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
||||
@@ -263,7 +269,7 @@ The same image serves both; only configuration differs.
|
||||
|
||||
| Hostname | Serves | Refuses (`404`) |
|
||||
| --- | --- | --- |
|
||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, the Dashboard's pages (`/modules`, `/settings`, `/account`, each with everything below it), and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
||||
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||
|
||||
@@ -339,7 +345,10 @@ networks:
|
||||
|
||||
Then make your proxy do what `Caddyfile.example` does:
|
||||
|
||||
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
|
||||
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
|
||||
and the Dashboard's pages: `/modules`, `/settings`, and `/account`, each bare and with everything
|
||||
below it (ADR 0057) — without them, refreshing any Dashboard page other than `/` is your proxy's
|
||||
`404`;
|
||||
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
||||
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
||||
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
||||
@@ -405,7 +414,7 @@ deployment:
|
||||
|
||||
| # | Item | Where it is met |
|
||||
| --- | --- | --- |
|
||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, the Dashboard's pages (`/modules`, `/settings`, `/account` and below), `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
||||
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
||||
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
||||
|
||||
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
|
||||
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
|
||||
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
|
||||
module routing is a design this pass should not settle by accident.
|
||||
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
|
||||
> now has host-owned URL routing; module routing remains a separate, deferred decision.
|
||||
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
|
||||
to the host, and adopting it here for a two-item selector would prejudge that design.
|
||||
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
|
||||
|
||||
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
|
||||
|
||||
## Deferred
|
||||
|
||||
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
|
||||
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
|
||||
> unchanged.
|
||||
|
||||
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
|
||||
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
|
||||
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
|
||||
|
||||
@@ -165,6 +165,10 @@ redirect target.
|
||||
A future client router may define an internal return-location contract, but it must be a separately
|
||||
validated, same-origin route decision. This pass does not create one speculatively.
|
||||
|
||||
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
|
||||
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
|
||||
> the current URL, which is now a real page address.
|
||||
|
||||
### Cookie and origin policy does not broaden for the Dashboard
|
||||
|
||||
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
|
||||
|
||||
@@ -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.
|
||||
@@ -65,3 +65,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
|
||||
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
|
||||
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
|
||||
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
|
||||
|
||||
@@ -343,7 +343,9 @@ permission the inventory does; organizing requires the new platform permission
|
||||
shell renders that arrangement and computes none of it.
|
||||
|
||||
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
|
||||
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment
|
||||
Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
|
||||
landing page have since landed; see
|
||||
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md)). A production CSP and deployment
|
||||
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
|
||||
pass 1 later supplies the host-owned sign-in flow described below.
|
||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||
|
||||
@@ -259,6 +259,14 @@ redirect target and performs no post-login navigation, so external and protocol-
|
||||
have no redirect sink in this pass. See
|
||||
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
|
||||
|
||||
Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL
|
||||
is a real page address: the API serves the Dashboard page at `/` and anywhere under `/modules`,
|
||||
`/settings`, and `/account` (each bare and with everything below it), with the same bytes, validator,
|
||||
and headers as `/`. A deep link opened while signed out therefore shows the sign-in form at that
|
||||
address and opens its own page once the person signs in — still with no return value and no
|
||||
redirect. Any other path, including look-alikes such as `/settings-old`, remains the sanitized
|
||||
`404`.
|
||||
|
||||
### The four anonymous screens
|
||||
|
||||
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
|
||||
|
||||
@@ -187,7 +187,9 @@ check(
|
||||
);
|
||||
|
||||
// --- A module UI, imported from the API's origin -------------------------------------------------
|
||||
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')";
|
||||
// The first module link — never the Overview, which is the Dashboard's own landing page (ADR 0057).
|
||||
const moduleButton =
|
||||
'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')';
|
||||
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
|
||||
if (hasModule) {
|
||||
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
|
||||
@@ -224,6 +226,20 @@ if (hasModule) {
|
||||
streams.every((response) => response.status === 200),
|
||||
`every realtime stream it opened was accepted (${streams.length} opened)`,
|
||||
);
|
||||
|
||||
// A refresh stays on the module's own page: the proxy forwards the deep link, the API serves the
|
||||
// Dashboard page there, and the shell mounts the same module again (ADR 0057).
|
||||
const moduleUrl = await evaluate("location.href");
|
||||
check(
|
||||
moduleUrl === `${dashboardOrigin}/modules/${moduleId}`,
|
||||
"opening the module put its own address in the address bar",
|
||||
);
|
||||
await navigate(moduleUrl);
|
||||
const remounted = await waitFor(
|
||||
`location.href === ${JSON.stringify(moduleUrl)} && ${selector(`select-${moduleId}`)}?.getAttribute("aria-current") === "page" && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
|
||||
"the module UI to mount again after a refresh",
|
||||
);
|
||||
check(remounted, `refreshing ${moduleUrl} reopens the ${moduleId} module`);
|
||||
}
|
||||
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
|
||||
// module unmounting, a navigation) is not.
|
||||
|
||||
@@ -88,6 +88,21 @@ async function edgeChecks() {
|
||||
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
|
||||
);
|
||||
|
||||
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057).
|
||||
for (const deepLink of ["/modules/example", "/settings/displays/add", "/account"]) {
|
||||
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
|
||||
check(
|
||||
deepPage.status === 200 &&
|
||||
deepPage.headers.get("etag") === page.headers.get("etag") &&
|
||||
(await deepPage.text()) === html,
|
||||
`GET ${deepLink} through the proxy is the same Dashboard page`,
|
||||
);
|
||||
}
|
||||
check(
|
||||
await refusedByProxy(`${dashboardOrigin}/settings-old`),
|
||||
"a look-alike of a Dashboard namespace is refused by the proxy",
|
||||
);
|
||||
|
||||
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
|
||||
await script.arrayBuffer();
|
||||
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
||||
@@ -152,6 +167,10 @@ async function splitRoutingChecks() {
|
||||
"the Dashboard hostname refuses /display/*",
|
||||
);
|
||||
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
|
||||
check(
|
||||
await refusedByProxy(`${apiOrigin}/settings/displays`),
|
||||
"the API hostname serves no Dashboard page either",
|
||||
);
|
||||
check(
|
||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
|
||||
"the API hostname refuses the Dashboard's script",
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"types": "./dist/dashboard-assets.d.ts",
|
||||
"import": "./dist/dashboard-assets.js"
|
||||
},
|
||||
"./dashboard-routes": {
|
||||
"types": "./dist/dashboard-routes.d.ts",
|
||||
"import": "./dist/dashboard-routes.js"
|
||||
},
|
||||
"./display-assets": {
|
||||
"types": "./dist/display-assets.d.ts",
|
||||
"import": "./dist/display-assets.js"
|
||||
|
||||
@@ -89,7 +89,13 @@ await copyFile(
|
||||
if (serve) {
|
||||
const context = await esbuild.context(buildOptions);
|
||||
await context.watch();
|
||||
const { host, port } = await context.serve({ servedir: outputDirectory });
|
||||
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
|
||||
// `/modules/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
|
||||
// Development only: the API serves the page at exactly the Dashboard's reserved paths instead.
|
||||
const { host, port } = await context.serve({
|
||||
fallback: path.join(outputDirectory, "index.html"),
|
||||
servedir: outputDirectory,
|
||||
});
|
||||
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
|
||||
console.log(
|
||||
apiBaseUrl === ""
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import { Button } from "@ekklesia/ui";
|
||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
HostAuthError,
|
||||
type HostAuthLanding,
|
||||
@@ -12,6 +20,7 @@ import {
|
||||
signInWithEmail,
|
||||
signOutHost,
|
||||
} from "../auth-protocol.js";
|
||||
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
|
||||
import {
|
||||
bootstrapHost,
|
||||
type DashboardNavigationEntry,
|
||||
@@ -20,8 +29,11 @@ import {
|
||||
HostProtocolError,
|
||||
loadHostPrincipal,
|
||||
} from "../host-protocol.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import { DevicePanel } from "./DevicePanel.js";
|
||||
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
|
||||
import { LoginForm } from "./LoginForm.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||
@@ -101,6 +113,15 @@ export interface HostAppProps {
|
||||
* so there is nothing an open redirect could use.
|
||||
*/
|
||||
readonly authLanding?: HostAuthLanding;
|
||||
/**
|
||||
* The address bar (ADR 0057). The current path decides which page is open, so a refresh, a
|
||||
* bookmark, and Back and Forward all land where the person was. The entry point passes the
|
||||
* browser's; tests pass an in-memory one. Must be a stable reference.
|
||||
*/
|
||||
readonly history: HostHistory;
|
||||
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
|
||||
* left unset, the shell names nothing. */
|
||||
readonly setDocumentTitle?: (title: string) => void;
|
||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
@@ -167,6 +188,8 @@ const shellStyles = {
|
||||
main: {
|
||||
boxSizing: "border-box",
|
||||
minWidth: 0,
|
||||
// A programmatic focus target after a page change (never in the tab order), not a control.
|
||||
outline: "none",
|
||||
overflowX: "auto",
|
||||
padding: designToken("spacing.xl"),
|
||||
},
|
||||
@@ -194,25 +217,30 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
|
||||
];
|
||||
}
|
||||
|
||||
function ModuleButton(props: {
|
||||
function ModuleLink(props: {
|
||||
readonly entry: DashboardNavigationEntry;
|
||||
readonly selected: boolean;
|
||||
readonly onSelect: () => void;
|
||||
readonly history: HostHistory;
|
||||
readonly route: DashboardRoute;
|
||||
}): ReactElement {
|
||||
const moduleId = props.entry.descriptor.id;
|
||||
return (
|
||||
<Button
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
aria-pressed={props.selected}
|
||||
data-testid={`select-${props.entry.descriptor.id}`}
|
||||
onClick={props.onSelect}
|
||||
style={{ justifyContent: "flex-start", width: "100%" }}
|
||||
variant="quiet"
|
||||
<DashboardLink
|
||||
current={props.route.kind === "module" && props.route.moduleId === moduleId}
|
||||
data-testid={`select-${moduleId}`}
|
||||
history={props.history}
|
||||
to={{ kind: "module", moduleId }}
|
||||
>
|
||||
{props.entry.name}
|
||||
</Button>
|
||||
</DashboardLink>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
|
||||
* not a page change. */
|
||||
function routeKey(route: DashboardRoute): string {
|
||||
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
|
||||
}
|
||||
|
||||
function HostMessage(props: {
|
||||
readonly children: string;
|
||||
readonly testId: string;
|
||||
@@ -265,21 +293,27 @@ async function resolveBootstrapState(
|
||||
/**
|
||||
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
|
||||
*
|
||||
* The login form replaces the protected application in place. There is no redirect parameter, URL
|
||||
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL
|
||||
* simply remains the current URL through sign-in and no external/protocol-relative return value can
|
||||
* become a navigation sink. Modules receive the same five-field context after authentication; they
|
||||
* neither render platform login nor receive sign-in/sign-out controls.
|
||||
* The login form replaces the protected application in place. There is no redirect parameter,
|
||||
* return value, or location assignment in this shell, so the current internal Dashboard URL simply
|
||||
* remains the current URL through sign-in — a deep link opens on its own page once the person has
|
||||
* signed in — and no external/protocol-relative value can become a navigation sink. The shell only
|
||||
* ever moves between its own paths (ADR 0057). Modules receive the same context after
|
||||
* authentication; they neither render platform login nor receive sign-in/sign-out controls.
|
||||
*/
|
||||
export function HostApp(props: HostAppProps): ReactElement {
|
||||
const [state, setState] = useState<BootstrapState>({ status: "loading" });
|
||||
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
|
||||
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
|
||||
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
|
||||
const revalidationRef = useRef<AbortController | undefined>(undefined);
|
||||
const generationRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const { config, importModule } = props;
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const shownRouteRef = useRef<string | undefined>(undefined);
|
||||
const { config, history, importModule, setDocumentTitle } = props;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
|
||||
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
||||
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
||||
// cannot resurrect a screen the person has left.
|
||||
@@ -306,14 +340,16 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
|
||||
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
|
||||
setState(resolved);
|
||||
if (resolved.status === "ready") {
|
||||
const first = allEntries(resolved.bootstrap)[0];
|
||||
setSelectedKey(first ? moduleKey(first) : undefined);
|
||||
} else {
|
||||
setSelectedKey(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// A non-canonical spelling of a real page (`/modules/example/`) is corrected in place, adding no
|
||||
// history entry, so what is bookmarked or shared next is the one canonical address.
|
||||
useEffect(() => {
|
||||
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
|
||||
history.replace(dashboardPath(route));
|
||||
}
|
||||
}, [history, pathname, route]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -329,7 +365,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
void bootstrapAttempt;
|
||||
const { controller, generation } = beginTransition();
|
||||
setState({ status: "loading" });
|
||||
setSelectedKey(undefined);
|
||||
void resolveBootstrapState(config, controller.signal).then((resolved) => {
|
||||
if (!isCurrent(controller, generation)) {
|
||||
return;
|
||||
@@ -381,7 +416,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const { controller, generation } = beginTransition();
|
||||
// The authenticated subtree disappears before the request starts. That synchronously schedules
|
||||
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
|
||||
setSelectedKey(undefined);
|
||||
setState({ status: "signing-out" });
|
||||
try {
|
||||
await signOutHost(config, controller.signal);
|
||||
@@ -435,7 +469,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const transition = beginTransition();
|
||||
transition.controller.abort();
|
||||
activeTransitionRef.current = undefined;
|
||||
setSelectedKey(undefined);
|
||||
setAuthView({ kind: "sign-in" });
|
||||
setState({ status: "anonymous" });
|
||||
}
|
||||
@@ -451,7 +484,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const transition = beginTransition();
|
||||
transition.controller.abort();
|
||||
activeTransitionRef.current = undefined;
|
||||
setSelectedKey(undefined);
|
||||
console.error("[ekklesia] session revalidation failed");
|
||||
setState({ status: "failed" });
|
||||
})
|
||||
@@ -464,6 +496,41 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
[beginTransition, config],
|
||||
);
|
||||
|
||||
const openEntry =
|
||||
state.status === "ready" && route.kind === "module"
|
||||
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
|
||||
: undefined;
|
||||
const pageTitle =
|
||||
state.status !== "ready"
|
||||
? undefined
|
||||
: route.kind === "overview"
|
||||
? "Overview"
|
||||
: route.kind === "module"
|
||||
? (openEntry?.name ?? "This page isn’t available")
|
||||
: "Page not found";
|
||||
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
|
||||
|
||||
useEffect(() => {
|
||||
if (documentTitle !== undefined) {
|
||||
setDocumentTitle?.(documentTitle);
|
||||
}
|
||||
}, [documentTitle, setDocumentTitle]);
|
||||
|
||||
// Moving to another page moves keyboard and screen-reader focus to the new page's content, as a
|
||||
// full page load would have. The first page shown after sign-in or a refresh is left alone, and a
|
||||
// canonical respelling of the same page is not a move.
|
||||
const currentRouteKey = routeKey(route);
|
||||
useEffect(() => {
|
||||
if (state.status !== "ready") {
|
||||
shownRouteRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
|
||||
mainRef.current?.focus();
|
||||
}
|
||||
shownRouteRef.current = currentRouteKey;
|
||||
}, [currentRouteKey, state.status]);
|
||||
|
||||
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
|
||||
|
||||
if (state.status === "loading") {
|
||||
@@ -553,7 +620,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
|
||||
const { bootstrap } = state;
|
||||
const entries = allEntries(bootstrap);
|
||||
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
|
||||
const { groups, ungrouped } = bootstrap.navigation;
|
||||
|
||||
return (
|
||||
@@ -569,56 +635,71 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
</header>
|
||||
<aside style={shellStyles.aside}>
|
||||
<DevicePanel config={config} />
|
||||
{entries.length === 0 && groups.length === 0 ? (
|
||||
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
||||
No installed module provides a user interface.
|
||||
</p>
|
||||
) : (
|
||||
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
{groups.map((group) => (
|
||||
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{group.modules.map((entry) => (
|
||||
<ModuleButton
|
||||
entry={entry}
|
||||
key={moduleKey(entry)}
|
||||
onSelect={() => setSelectedKey(moduleKey(entry))}
|
||||
selected={moduleKey(entry) === selectedKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 ? (
|
||||
<section data-testid="group-ungrouped">
|
||||
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{ungrouped.map((entry) => (
|
||||
<ModuleButton
|
||||
entry={entry}
|
||||
key={moduleKey(entry)}
|
||||
onSelect={() => setSelectedKey(moduleKey(entry))}
|
||||
selected={moduleKey(entry) === selectedKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : undefined}
|
||||
</nav>
|
||||
)}
|
||||
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<DashboardLink
|
||||
current={route.kind === "overview"}
|
||||
data-testid="select-overview"
|
||||
history={history}
|
||||
to={{ kind: "overview" }}
|
||||
>
|
||||
Overview
|
||||
</DashboardLink>
|
||||
{entries.length === 0 && groups.length === 0 ? (
|
||||
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
||||
No installed module provides a user interface.
|
||||
</p>
|
||||
) : undefined}
|
||||
{groups.map((group) => (
|
||||
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{group.modules.map((entry) => (
|
||||
<ModuleLink
|
||||
entry={entry}
|
||||
history={history}
|
||||
key={moduleKey(entry)}
|
||||
route={route}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 ? (
|
||||
<section data-testid="group-ungrouped">
|
||||
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{ungrouped.map((entry) => (
|
||||
<ModuleLink
|
||||
entry={entry}
|
||||
history={history}
|
||||
key={moduleKey(entry)}
|
||||
route={route}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : undefined}
|
||||
</nav>
|
||||
</aside>
|
||||
<main style={shellStyles.main}>
|
||||
{selected ? (
|
||||
<ModuleUiFrame
|
||||
config={config}
|
||||
descriptor={selected.descriptor}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
key={moduleKey(selected)}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : undefined}
|
||||
<main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
|
||||
{route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
openEntry ? (
|
||||
<ModuleUiFrame
|
||||
config={config}
|
||||
descriptor={openEntry.descriptor}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
key={moduleKey(openEntry)}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : (
|
||||
<ModuleUnavailablePage history={history} />
|
||||
)
|
||||
) : (
|
||||
<NotFoundPage history={history} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { consumeAuthLanding } from "../auth-protocol.js";
|
||||
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
|
||||
import { HostApp } from "./HostApp.js";
|
||||
import { createBrowserHostHistory } from "./host-history.js";
|
||||
|
||||
/**
|
||||
* The one place the shell touches a browser global.
|
||||
@@ -23,6 +24,13 @@ const apiBaseUrl = resolveApiOrigin(
|
||||
* `consumeAuthLanding` for why the token must not stay in the URL. */
|
||||
const authLanding = consumeAuthLanding(window.location, window.history);
|
||||
|
||||
/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */
|
||||
const history = createBrowserHostHistory(window);
|
||||
|
||||
const setDocumentTitle = (title: string): void => {
|
||||
document.title = title;
|
||||
};
|
||||
|
||||
const container = document.getElementById("ekklesia-host");
|
||||
if (!container) {
|
||||
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
|
||||
@@ -53,9 +61,14 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
|
||||
);
|
||||
}
|
||||
return authLanding ? (
|
||||
<HostApp authLanding={authLanding} config={config} />
|
||||
<HostApp
|
||||
authLanding={authLanding}
|
||||
config={config}
|
||||
history={history}
|
||||
setDocumentTitle={setDocumentTitle}
|
||||
/>
|
||||
) : (
|
||||
<HostApp config={config} />
|
||||
<HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import { createMemoryHostHistory } from "../src/browser/host-history.js";
|
||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||
|
||||
/**
|
||||
@@ -94,7 +95,13 @@ function platform(stub: AuthStub = {}): Platform {
|
||||
|
||||
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
|
||||
root.render(
|
||||
<HostApp
|
||||
config={config}
|
||||
history={createMemoryHostHistory("/")}
|
||||
{...(authLanding ? { authLanding } : {})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { act, Component, type ReactElement, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import { createMemoryHostHistory } from "../src/browser/host-history.js";
|
||||
import { DisplayApp } from "../src/display/DisplayApp.js";
|
||||
import type {
|
||||
EkklesiaHostConfig,
|
||||
@@ -393,7 +394,15 @@ describe("display host — full-viewport boundary", () => {
|
||||
});
|
||||
};
|
||||
|
||||
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<HostApp
|
||||
config={config}
|
||||
history={createMemoryHostHistory("/modules/announcements")}
|
||||
importModule={importer}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
// The management artifact rendered inside its own isolated root, not the shell's own tree.
|
||||
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
|
||||
"announcement management",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import { createBrowserHostHistory } from "../src/browser/host-history.js";
|
||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
|
||||
|
||||
@@ -122,12 +123,27 @@ function artifact(id: string, version: string, options: ArtifactOptions = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the shell over the real (happy-dom) address bar. `path` opens that page first, exactly as
|
||||
* a deep link or a refresh would (ADR 0057); left out, the shell opens wherever the location already
|
||||
* is — `/`, the Overview, unless a test put something else there.
|
||||
*/
|
||||
async function render(
|
||||
config: EkklesiaHostConfig,
|
||||
importModule?: (url: string) => Promise<unknown>,
|
||||
path?: string,
|
||||
): Promise<void> {
|
||||
if (path !== undefined) {
|
||||
window.history.replaceState(null, "", path);
|
||||
}
|
||||
await act(async () => {
|
||||
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />);
|
||||
root.render(
|
||||
<HostApp
|
||||
config={config}
|
||||
history={createBrowserHostHistory(window)}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,6 +284,11 @@ describe("Ekklesia shell — bootstrap", () => {
|
||||
});
|
||||
|
||||
describe("Ekklesia shell — browser authentication", () => {
|
||||
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
|
||||
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/modules/example");
|
||||
});
|
||||
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
|
||||
let releasePrincipal: (() => void) | undefined;
|
||||
const principalPending = new Promise<void>((resolve) => {
|
||||
@@ -539,6 +560,11 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
});
|
||||
|
||||
describe("Ekklesia shell — logout and session expiration", () => {
|
||||
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
|
||||
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/modules/example");
|
||||
});
|
||||
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
|
||||
let releaseSignOut: (() => void) | undefined;
|
||||
const signOutPending = new Promise<void>((resolve) => {
|
||||
@@ -856,10 +882,8 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
||||
|
||||
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
|
||||
expect(labels).toEqual(["Planning", "Media"]);
|
||||
const buttons = [...container.querySelectorAll("nav button")].map(
|
||||
(button) => button.textContent,
|
||||
);
|
||||
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
|
||||
const links = [...container.querySelectorAll("nav section a")].map((link) => link.textContent);
|
||||
expect(links).toEqual(["Calendar", "Livestream", "Video"]);
|
||||
});
|
||||
|
||||
it("renders ungrouped modules alongside groups, under a heading", async () => {
|
||||
@@ -897,14 +921,29 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
||||
expect(query("group-media")?.textContent).toBe("Media");
|
||||
});
|
||||
|
||||
it("selects the first module in the arranged order and marks it active", async () => {
|
||||
it("opens on the Overview rather than choosing a module, whatever the arrangement", async () => {
|
||||
const importModule = vi.fn(async () => artifact("video", "0.1.0"));
|
||||
await render(
|
||||
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
|
||||
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"),
|
||||
importModule,
|
||||
);
|
||||
|
||||
expect(query("select-video")?.getAttribute("aria-current")).toBe("page");
|
||||
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull();
|
||||
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
|
||||
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
|
||||
expect(query("module-container")).toBeNull();
|
||||
expect(importModule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the open module's link as the current page", async () => {
|
||||
await render(
|
||||
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
|
||||
async () => artifact("livestream", "2.0.0"),
|
||||
"/modules/livestream",
|
||||
);
|
||||
|
||||
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
|
||||
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
|
||||
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
|
||||
@@ -915,6 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
||||
requested.push(url);
|
||||
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
|
||||
},
|
||||
"/modules/video",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -972,6 +1012,11 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
||||
});
|
||||
|
||||
describe("Ekklesia shell — mount lifecycle", () => {
|
||||
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
|
||||
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/modules/example");
|
||||
});
|
||||
it("keeps fixed shell tracks while module content changes size", async () => {
|
||||
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
|
||||
|
||||
@@ -1140,6 +1185,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
});
|
||||
|
||||
describe("Ekklesia shell — failure is host-owned", () => {
|
||||
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
|
||||
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/modules/example");
|
||||
});
|
||||
it("renders its own error when the artifact cannot be imported", async () => {
|
||||
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
|
||||
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
"include": [
|
||||
"src/index.ts",
|
||||
"src/dashboard-assets.ts",
|
||||
"src/dashboard-routes.ts",
|
||||
"src/display-assets.ts",
|
||||
"src/host-protocol.ts",
|
||||
"src/module-context.ts"
|
||||
|
||||
+22
-1
@@ -188,7 +188,12 @@ describe("reference deployment compose files", () => {
|
||||
.filter((line) => !line.trim().startsWith("#"))
|
||||
.join("\n");
|
||||
|
||||
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
|
||||
// The Dashboard's reserved namespaces (ADR 0057, `DASHBOARD_PAGE_PREFIXES` in
|
||||
// `surfaces/shell/src/dashboard-routes.ts`), each both bare and with everything below it, so a
|
||||
// refreshed deep link reaches the API instead of the proxy's 404.
|
||||
expect(directives).toContain(
|
||||
"@ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/*",
|
||||
);
|
||||
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
|
||||
expect(directives).toContain("header_up -CF-Connecting-IP");
|
||||
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
|
||||
@@ -200,6 +205,22 @@ describe("reference deployment compose files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split-origin reference proxy", () => {
|
||||
it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => {
|
||||
const caddyfile = await text("Caddyfile.split.example");
|
||||
const directives = caddyfile
|
||||
.split("\n")
|
||||
.filter((line) => !line.trim().startsWith("#"))
|
||||
.join("\n");
|
||||
|
||||
expect(directives).toContain(
|
||||
"@dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/*",
|
||||
);
|
||||
// The API hostname still serves no Dashboard page at all.
|
||||
expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reference deployment env examples", () => {
|
||||
it("contain no secret values", async () => {
|
||||
const secretKeys = new Map([
|
||||
|
||||
Reference in New Issue
Block a user