feat(shell): make module pages first-class top-level routes
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s

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]>
This commit is contained in:
ekklesia
2026-10-14 08:42:34 -05:00
co-authored by Claude Opus 5.5
parent 9afe9376c4
commit ba60f70835
26 changed files with 745 additions and 334 deletions
@@ -30,50 +30,91 @@ page at the new paths, and the proxies had to forward them.
## Decision
### The Dashboard owns a closed set of namespaces
### One top-level URL space, shared by platform pages and module pages
```text
/ the Overview
/modules/<module-id> a module's page
/settings[/…] reserved for Settings
/account[/…] reserved for the account area
/ the Overview
/settings[/…] Settings
/account[/…] the account area
/<module-id> a module's page: /signage, /calendar, /people
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
```
`DASHBOARD_PAGE_PREFIXES` (`surfaces/shell/src/dashboard-routes.ts`, exported as
`@ekklesia/shell/dashboard-routes`) lists `/modules`, `/settings`, and `/account`. It is the one
list: the shell's router parses against it, and `apps/api` registers its page routes from it, so the
server cannot serve a path the shell does not interpret and the shell cannot link to a path the
server refuses. None of the three overlaps a namespace something else owns — `/api`, `/display`, or
`/assets` — and a test holds that.
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
Account area are. "Module" is an implementation concept — how the platform packages a product — and
it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
kebab-case and Core-validated, which is what makes it usable as a path segment with no further
encoding.
The namespaces are reserved whole, including `/settings` and `/account`, which have no pages yet.
Reserving them now means that adding a Settings or Account page never requires another server change
or another edit to every installation's proxy configuration, which is an operator's job and easy to
miss.
### Platform-owned top-level names are reserved, and Core enforces it
Module pages live under a prefix rather than at the root (`/signage`) so that no module id can ever
collide with a platform namespace and no platform namespace added later can shadow an installed
module. Module ids are manifest-validated and Core-owned; reserving words in that grammar would be a
contract change for a cosmetic gain. The prefix is an internal identifier in ADR 0046's sense, not
branding.
Sharing one namespace means a module must never be able to take a name the platform uses.
`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
at the top level **today**, taken from what is actually registered rather than from guesses:
### The server serves one page, unchanged, at every Dashboard path
| Name | Owned by |
| --- | --- |
| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
| `assets` | the Dashboard's and display host's scripts |
| `display` | the display host's pages and service worker |
| `settings` | the Dashboard's Settings pages |
| `account` | the Dashboard's account area |
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at each prefix, and at
each `prefix/*`, with **the same frozen bytes, the same strong `ETag`, and the same security headers**
as before. The path selects a page inside the shell, never a file on the server: nothing from the
request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths. A look-alike (`/settings-old`, `/modulesx`), any other
method, and every other path still fall through to the sanitized `404`. The page grants no authority
wherever it is served; it renders the sign-in form until `/api/v1/principal` says otherwise.
Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
An unknown page inside a reserved namespace is answered `200` with the page, and the shell renders its
own *Page not found*. That is the price of letting the shell, which owns page meaning, decide what
exists; the server knows nothing of modules' or Settings' pages and should not.
The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
composed API registers is a reserved name or a module page, the display host's and Dashboard
script's path constants all start with a reserved name, and every Dashboard page the shell
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
is reserved. Reserving a name later is a **breaking change** for any installation with a module of
that id, so the set grows only deliberately, and only for a real route.
Both reference proxies forward exactly these paths (each prefix bare and with `/*`). In the split
topology (ADR 0045) they belong to the Dashboard hostname; the API hostname still serves no Dashboard
page. The development server falls back to the page for any path with no file.
The rule lives in Core's discovery rather than in the module manifest grammar
(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
standalone host (ADR 0021) has no Settings page to protect. A module built against the current
public packages is unaffected unless it uses one of these five names.
### The server serves one page, unchanged, at every real Dashboard address — and nowhere else
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and
`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as
`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at
`/<module-id>` and everything below it for **each module this process discovered that declares a
Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security
headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from
the request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths.
The module set is the boot snapshot's discovered inventory, fixed at startup like every other
module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a
module an administrator turned off still opens the Dashboard, which says the module is unavailable;
that is more useful than an address that suddenly looks like it never existed. A module that is not
installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`),
a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
appears twice — discovery already guarantees all three, and the routes do not trust it.
Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a
Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the
display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather
than any data, and the alternative — a page at every module-shaped root path — is exactly the
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
and every module route keep their authorization unchanged.
### The proxies forward module-shaped names and let the API decide
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API
answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`,
`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The
development server falls back to the page for any path with no file.
### The shell's router is host-owned, small, and deliberately not a library
@@ -94,17 +135,19 @@ a separate decision (see **Deferred**).
- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
show yet.
- `/modules/<id>` mounts the module whose navigation entry has that id, through the unchanged generic
- `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic
loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's
navigation is the only authority on what exists. An id it does not list — a disabled, blocked, or
removed module, one with no UI, or a typo — shows *This page isn't available* and loads nothing;
the page never echoes the address back. This is what makes a disabled module's routes stop behaving
as active routes, with no new server state.
- Everything else — including anything below `/modules/<id>/` and every `/settings` and `/account`
path for now — is *Page not found*, with a link to the Overview. The shell never guesses.
navigation is the only authority on what is mountable. An id it does not list — a disabled or
blocked module whose address the server still serves — shows *This page isn't available* and loads
nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
behaving as active routes, with no new server state.
- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
have no content yet, so they show *Page not found* for now.
- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today
and shows *Page not found* in the shell until the module page contract exists. The shell never
guesses, and never shows a module's first page for an address the module never defined.
- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new
entry), so what is bookmarked next is the one canonical address. Module ids are percent-encoded
into exactly one segment.
entry), so what is bookmarked next is the one canonical address.
### Navigation is real links
@@ -132,35 +175,45 @@ verification and reset links still land on `/` with a query (ADR 0038).
## Alternatives
- **Hash routing (`/#/modules/signage`).** Rejected: it needs no server change, but every address is
then one page to the server, the proxy, and any analytics; fragments are not sent to the server, so a
later server-side decision about a path is impossible; and DESIGN.md asks for real URLs.
- **Module pages at the root (`/signage/…`).** Rejected: module ids would collide with platform
namespaces, and reserving words would be a manifest-grammar change for a shorter address.
- **A server catch-all that serves the page for any unknown `GET`.** Rejected: it would answer `200`
for every mistyped API or asset path a browser requests, and it reverses the ADR 0044 posture that
the API serves only paths it means to.
- **Listing only the pages that exist (`/`, `/modules/*`).** Rejected: every Settings or Account page
would then need a server change and every installation's proxy edited again.
- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
in every address people see, and makes a module a second-class destination beside Settings. The
collision it avoided is handled instead by reserving the platform's names in Core.
- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
any analytics; fragments never reach the server, so no server-side decision about a path is
possible; and DESIGN.md asks for real URLs.
- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
would become a Dashboard page, and the server would answer `200` for every mistyped address. It
would remove the installed-module disclosure described above, at the cost of the server no longer
meaning anything by a `200`.
- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an
administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the
Dashboard explaining that the module is unavailable.
- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit.
- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved
name refuses a real module, so a name is reserved when the platform gets a route there and not
before.
- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's
URL space, not a module property, and would need a public-contract release for no extra safety,
since discovery already refuses the module before it can be installed.
- **`react-router-dom` or another routing library.** Rejected for now; see above.
- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page,
and an address that silently means "whatever happens to be first" is not a stable address.
- **Showing the module's page for any path below `/modules/<id>/`.** Rejected: a module's own pages
need a contract. Mounting the module's first page for an address it never defined would make a
later contract break bookmarks people had already made.
## Consequences
- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the
Dashboard's own pages. Proven at three levels: the shell against an in-memory history, `apps/api`
serving the page at every reserved namespace and refusing look-alikes, and the deploy smoke test
reloading a module's address through the real split-topology proxy in a real browser.
Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown
names, a module's address staying served while the module is disabled and disappearing and
returning as it is removed and reinstalled,
and reloading a module's address in a real browser and requiring the same module to mount again.
- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in
Signage now chooses it once from the navigation, and can bookmark its address.
- **Operators with their own proxy must forward the new paths.** An installation using the reference
Caddyfiles gets them by copying the new examples; one with its own proxy must add `/modules`,
`/settings`, `/account` and everything below each, or a refresh on those pages will be the proxy's
`404`. The deployment guide says so.
Signage now chooses it once from the navigation, and can bookmark `/signage`.
- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so.
- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains
private; this is the same relationship `./dashboard-assets` already has.
- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the
@@ -168,11 +221,14 @@ verification and reset links still land on `/` with a query (ADR 0038).
## Deferred
Each of these needs its own decision; this ADR reserves addresses for them and decides none of them:
Each of these needs its own decision. This ADR reserves the addresses for them and decides none of
them:
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar, how
it learns and changes its current sub-path through the module UI SDK, and what an address below
`/modules/<id>/` means. That is a manifest and SDK contract change and its own ADR.
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar
(`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The
server already serves the page there and the proxies already forward it, so that contract is a
manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR.
- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and
comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain
a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page