Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`), beside `/settings` and `/account`, so the implementation concept "module" never appears in a user-facing address. ADR 0057 is rewritten to match. - Core reserves the top-level names the platform actually serves today (`account`, `api`, `assets`, `display`, `settings`), and discovery refuses a module that declares one with `MODULE_ID_RESERVED`. That covers startup, `modules:validate`, and package verification. Tests in `apps/api` hold the set to every top-level route the API registers and every page the shell interprets. - The API serves the Dashboard page at `/<module-id>` and below only for modules discovered with a Dashboard UI, enabled or not: a disabled module's bookmark still opens the Dashboard, which says it is unavailable. An arbitrary root path such as `/definitely-not-a-real-module` stays the sanitized 404. - Both reference proxies forward every top-level path shaped like a module id and let the API decide; the split Dashboard hostname still refuses `/api`, `/display`, and `/assets`. Installing a module needs no proxy change. - `/<module-id>/<page>` is already served and forwarded, so a later module sub-page contract needs no routing or deployment change; the shell shows "Page not found" there until it exists. - The deploy smoke checks a module's address before install, while installed, while disabled, after removal, and after reinstall, plus unknown names and the old `/modules/…` form through the real proxy. Co-Authored-By: Claude Opus 5.5 <[email protected]>
16 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
One top-level URL space, shared by platform pages and module pages
/ the Overview
/settings[/…] Settings
/account[/…] the account area
/<module-id> a module's page: /signage, /calendar, /people
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the Account area are. "Module" is an implementation concept — how the platform packages a product — and it does not appear in the addresses people see, bookmark, and share. A module id is lowercase kebab-case and Core-validated, which is what makes it usable as a path segment with no further encoding.
Platform-owned top-level names are reserved, and Core enforces it
Sharing one namespace means a module must never be able to take a name the platform uses.
RESERVED_MODULE_IDS (core/src/modules/reserved-ids.ts) is the complete set the platform serves
at the top level today, taken from what is actually registered rather than from guesses:
| Name | Owned by |
|---|---|
api |
the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
assets |
the Dashboard's and display host's scripts |
display |
the display host's pages and service worker |
settings |
the Dashboard's Settings pages |
account |
the Dashboard's account area |
Discovery refuses a module that declares one (MODULE_ID_RESERVED), so the check covers API
startup, pnpm modules:validate, and Module Package verification — before a module is installed,
not only when it is served. Exact names only: settings-sync or displays are ordinary ids.
The set is held to reality by tests in apps/api rather than by review: every top-level segment the
composed API registers is a reserved name or a module page, the display host's and Dashboard
script's path constants all start with a reserved name, and every Dashboard page the shell
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
is reserved. Reserving a name later is a breaking change for any installation with a module of
that id, so the set grows only deliberately, and only for a real route.
The rule lives in Core's discovery rather than in the module manifest grammar
(@ekklesia/contracts), because it is the Ekklesia host's URL space, not a property of a module: a
standalone host (ADR 0021) has no Settings page to protect. A module built against the current
public packages is unaffected unless it uses one of these five names.
The server serves one page, unchanged, at every real Dashboard address — and nowhere else
createDashboardHostRoutes answers GET (and Hono's implicit HEAD) at /, at /settings and
/account and everything below each (DASHBOARD_PLATFORM_PAGES, exported by the shell as
@ekklesia/shell/dashboard-routes, so the server and the shell's router read one list), and at
/<module-id> and everything below it for each module this process discovered that declares a
Dashboard UI. Every answer is the same frozen bytes, the same strong ETag, and the same security
headers as /: the path selects a page inside the shell, never a file on the server. Nothing from
the request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths.
The module set is the boot snapshot's discovered inventory, fixed at startup like every other module-derived route, and it deliberately includes disabled and blocked modules. A bookmark to a module an administrator turned off still opens the Dashboard, which says the module is unavailable; that is more useful than an address that suddenly looks like it never existed. A module that is not installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
Everything else stays the sanitized 404: an arbitrary root path (/definitely-not-a-real-module),
a look-alike (/settings-old, /signages), another method, /index.html, and so on. The routes
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
appears twice — discovery already guarantees all three, and the routes do not trust it.
Serving /<module-id> before sign-in means an anonymous request can learn whether a module with a
Dashboard UI is installed, by its 200 or 404. That is accepted: it is the same disclosure the
display host already makes at /display/<module-id> (ADR 0033), a module id names a product rather
than any data, and the alternative — a page at every module-shaped root path — is exactly the
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
and every module route keep their authorization unchanged.
The proxies forward module-shaped names and let the API decide
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
whose first segment has a module id's shape, ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$, and the API
answers it with the page or its sanitized 404. Anything not shaped like one — /favicon.ico,
/.well-known/…, an uppercase name — is still refused by the proxy without reaching the API. In the
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
/api, /display, and /assets, and the API hostname still serves no Dashboard page. The
development server falls back to the page for any path with no file.
The shell's router is host-owned, small, and deliberately not a library
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./<module-id>mounts the module whose navigation entry has that id, through the unchanged generic loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's navigation is the only authority on what is mountable. An id it does not list — a disabled or blocked module whose address the server still serves — shows This page isn't available and loads nothing; the page never echoes the address back. This is what makes a disabled module's routes stop behaving as active routes, with no new server state./settings…and/account…are the platform's own pages and are never read as module pages. They have no content yet, so they show Page not found for now.- Everything below
/<module-id>/belongs to that module's own pages. It is served by the API today and shows Page not found in the shell until the module page contract exists. The shell never guesses, and never shows a module's first page for an address the module never defined. - One trailing slash is tolerated and corrected to the canonical spelling in place (
replace, no new entry), so what is bookmarked next is the one canonical address.
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
- Module pages under a prefix (
/modules/<id>). Rejected: it exposes an implementation concept in every address people see, and makes a module a second-class destination beside Settings. The collision it avoided is handled instead by reserving the platform's names in Core. - Hash routing (
/#/signage). Rejected: every address is one page to the server, the proxy, and any analytics; fragments never reach the server, so no server-side decision about a path is possible; and DESIGN.md asks for real URLs. - Serving the page at any module-shaped root path. Rejected:
/definitely-not-a-real-modulewould become a Dashboard page, and the server would answer200for every mistyped address. It would remove the installed-module disclosure described above, at the cost of the server no longer meaning anything by a200. - Serving only enabled, running modules' pages. Rejected: a bookmark to a module an
administrator disabled would become a bare
404, indistinguishable from a typo, instead of the Dashboard explaining that the module is unavailable. - Listing modules in the proxy configuration. Rejected: every install would need a proxy edit.
- Reserving more names now (
login,help,overview, …) "just in case". Rejected: a reserved name refuses a real module, so a name is reserved when the platform gets a route there and not before. - Putting the reserved names in the manifest grammar. Rejected for now: it is the Ekklesia host's URL space, not a module property, and would need a public-contract release for no extra safety, since discovery already refuses the module before it can be installed.
react-router-domor 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.
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/apiserving the page at platform pages and installed modules' pages and the sanitized404everywhere else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown names, a module's address staying served while the module is disabled and disappearing and returning as it is removed and reinstalled, and reloading a module's address in a real browser and requiring the same module to mount again. - The Dashboard opens on the Overview, not on the first module. A person who used to land in
Signage now chooses it once from the navigation, and can bookmark
/signage. - A module may not be called
account,api,assets,display, orsettings. No known module is; one that was would now fail discovery withMODULE_ID_RESERVEDand must be renamed. - Operators with their own proxy must forward module-shaped top-level paths (the matcher above),
or a refresh on any page other than
/is the proxy's404. The deployment guide says so. @ekklesia/shellgains a./dashboard-routessubpath, andapps/apireads it. The surface remains private; this is the same relationship./dashboard-assetsalready 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 the addresses for them and decides none of them:
- A module's own pages — how a module declares the pages that appear under it in the sidebar
(
/signage/playback,/signage/timelines,/signage/displays), how it learns and changes its current sub-path through the module UI SDK, and what an address below/<module-id>/means. The server already serves the page there and the proxies already forward it, so that contract is a manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR. - Return to origin — how a module or a Settings page links into another part of the Dashboard and comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page 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.