Files
Ekklesia/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md
T
ekklesiaandClaude Opus 5.5 9afe9376c4
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
feat(shell): give every Dashboard page a real URL
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]>
2026-10-14 08:11:02 -05:00

12 KiB

0057: Dashboard URLs are real, host-owned routes

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

/                     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.

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.