feat(shell): give every Dashboard page a real URL, with modules at /<module-id> (Stage 1, ADR 0057) #103

Merged
Joey merged 2 commits from feat/dashboard-url-routing into main 2026-10-14 22:07:20 +00:00
Member

First Stage 1 slice: the Dashboard gets real URLs. The decision is recorded in ADR 0057.

Revised before merge: module pages are now first-class top-level routes, /<module-id> (/signage), not /modules/<id>. Platform-owned top-level names are reserved, and Core enforces the reservation. The second commit has the change; ADR 0057 has been rewritten to match.

Why this first

Every later Stage 1 page needs a stable address: Settings, Account, Settings → Displays → Add display, a module's own pages in the sidebar, and returning to the workflow a Settings link came from. Until now the Dashboard had one URL. apps/api served the page only at /, both reference proxies forwarded only /, and the open module lived in React state. A refresh always went back to the first module, nothing could be bookmarked, and Back left the Dashboard. (DESIGN.md §2.7, §5; ROADMAP §5.2.)

URL model

/                          Overview (the landing page)
/settings[/…]              Settings (reserved; pages come later)
/account[/…]               Account (reserved; pages come later)
/<module-id>               a module's page: /signage, /calendar, /people
/<module-id>/<page>[/…]    reserved for a module's own pages: /signage/timelines

"Module" never appears in a user-facing address.

What people will see

  • The Dashboard opens on an Overview at /, not on the first module. The Overview says honestly that it has nothing to show yet.
  • Each module has its own top-level address. Refresh, bookmarks, shared links, and Back/Forward all land on the page that was open.
  • Navigation items are real links. Ctrl/⌘-click or middle-click opens a page in a new tab. The open page is marked with aria-current="page" and shown by weight as well as colour.
  • An installed module that is disabled (or blocked) still has its address, and shows This page isn't available while loading nothing. An address that is not a Dashboard page at all, such as /definitely-not-a-real-module, gets the API's normal sanitized 404.
  • A deep link opened while signed out shows sign-in at that address, then opens its own page after sign-in. ADR 0034 is unchanged: no return parameter, no redirect.
  • After an in-place page change, focus moves to the page's <main>, and the browser tab is named after the page. It never says "Ekklesia".

How

Reserved top-level names (Core)

RESERVED_MODULE_IDS in core/src/modules/reserved-ids.ts is the set the platform actually serves at the top level today: api, assets, display, settings, and account. Discovery refuses a module that declares one with a new issue code, MODULE_ID_RESERVED. That covers API startup, pnpm modules:validate, and Module Package verification. Look-alikes such as settings-sync and displays are fine.

Tests in apps/api hold the set to reality:

  • every top-level segment the composed API registers is reserved, or is a module page;
  • every display host and Dashboard script path constant starts with a reserved name;
  • every Dashboard page the shell interprets is reserved.

A new top-level platform route therefore fails a test until it is reserved.

API

createDashboardHostRoutes serves the same frozen bytes, ETag, and security headers at:

  • /;
  • /settings and /account, each with everything below it;
  • /<module-id> with everything below it, for each discovered module that declares a Dashboard UI (enabled or not).

The set is fixed at startup, like every other module-derived route. Not-installed modules, display-only modules, look-alikes (/signages, /settings-old), the old /modules/… form, other methods, and every other path stay the sanitized 404. Composition refuses a module page that is not a valid id, is reserved, or is duplicated.

Shell

  • @ekklesia/shell/dashboard-routes exports the Dashboard's platform pages (settings, account) and the path parser. The server and the router read the same list.
  • A small host-owned HostHistory replaces the selected-module state. The entry point stays the only file that touches window.
  • No routing library.

Proxies

A proxy can't know which modules are installed, so both reference Caddyfiles gain a @dashboardPage matcher that forwards every top-level path shaped like a module id: ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$. The API decides between the page and a 404. Anything else, such as /favicon.ico or /.well-known/…, is still refused by the proxy.

In the split topology, the Dashboard hostname still refuses /api, /display, and /assets, and the API hostname still serves no Dashboard page. Installing or removing a module needs no proxy change. Both files were adapted with real caddy:2.

Subpages, later

/<module-id>/<page> is already served by the API and forwarded by the proxies. The future module page contract (manifest, SDK, and shell) therefore needs no routing or deployment change. Until it exists, those addresses show the shell's Page not found.

Points to review

  • New module rule: a module may not be called account, api, assets, display, or settings. No known module is. One that was would fail discovery and must be renamed.
  • Installed-module disclosure: serving /<module-id> before sign-in lets an anonymous request learn whether a module with a Dashboard UI is installed (200 vs 404). This is the same disclosure the display host already makes at /display/<id> (ADR 0033), and it is recorded in the ADR. The alternative, a page at every module-shaped path, is exactly the "arbitrary path becomes a Dashboard page" this PR rejects.
  • No contracts change: the reservation is enforced by Core's discovery, not by the public @ekklesia/contracts manifest grammar. It is the Ekklesia host's URL space, and a standalone host has no Settings page to protect.

⚠️ Operator impact

An installation with its own proxy must forward module-shaped top-level paths using the matcher above. Without that, the Dashboard still opens at /, but refreshing any other page returns the proxy's 404. Installations using the reference Caddyfiles copy the new examples. The deployment guide covers this in Upgrading and in the own-proxy checklist, and tells operators not to list module ids.

Stage 0 behaviour preserved

  • The display host (/display/*, its script, and its service worker) is untouched, and paired displays are unaffected. display is now a reserved module id.
  • Emailed verification and reset links still land on / with a query.
  • Module mounting, the keyed frame (stale-load protection), session-expiry handling, and sign-out cleanup are unchanged and still covered by their existing tests.

Tests

  • Core: module-discovery.test.ts checks that each reserved id is refused with MODULE_ID_RESERVED and that look-alikes are accepted.
  • Shell:
    • dashboard-routes.test.ts: top-level module pages, platform pages never read as modules, sub-paths not-found for now, encoding, canonical spelling.
    • host-history.test.ts.
    • host-routing.test.tsx: landing, deep links, unavailable and not-found pages, trailing-slash canonicalization, sign-in at a deep link, real links, modified clicks, Back/Forward, focus, titles, no /modules/ anywhere.
    • Existing shell tests now open their module's top-level address.
  • API: dashboard-host-routes.test.ts covers:
    • the same page, ETag, and headers at platform pages and installed (including disabled) modules' pages and sub-paths;
    • a 304 on a deep link;
    • sanitized 404 for unknown names, display-only and uninstalled modules, look-alikes, and /modules/…;
    • other methods refused;
    • composition refusing reserved, invalid, or duplicate module pages;
    • the reserved-set-versus-registered-routes checks described above.
  • Deploy config: tests/deploy.test.ts checks both Caddyfiles' matchers, and tests the regular expression itself against paths it must and must not forward.
  • Deploy smoke:
    • Through the real proxy: platform deep links are the same page; unknown names, look-alikes, and /modules/example are the API's sanitized 404.
    • A module's address is a 404 before install, the page while installed, still the page while disabled, a 404 again once removed, and the page again after reinstall.
    • In a real browser, opening the module puts /<id> in the address bar, and reloading it reopens the same module.
    • module_check gained a separate page expectation, because "not running" (disabled) and "not installed" are different here.

Run locally

All of these ran on the pushed commit:

  • pnpm verify: passed.
  • pnpm infrastructure:config and pnpm deploy:config: passed.
  • pnpm test:migrations:integration: passed, 15 files / 401 tests.
  • bash scripts/smoke-deploy.sh (same-origin): passed.
  • EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh: passed.
  • caddy adapt for both Caddyfiles: matchers and handle order as intended.
  • Not run: test:package-release and test:package-publication. No public @ekklesia/* package changed; Core, the API, and the shell are private.

Not in this PR (later Stage 1 slices, per ADR 0057 Deferred)

  • A module's own sub-pages and their manifest/SDK contract.
  • Return-to-origin for Settings deep links.
  • The Settings and Account pages themselves.
  • The collapsible sidebar and mobile drawer.
  • Overview cards.
  • Branding: the static <title> and the header's hard-coded name are unchanged here.

Issues filed while doing this

  • #101: ADR 0046's startup-configured branding conflicts with DESIGN.md's Settings-managed branding.
  • #102: CLAUDE.md still forbids starting Stage 1 work.

🤖 Generated with Claude Code

First Stage 1 slice: **the Dashboard gets real URLs.** The decision is recorded in [ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md). > **Revised before merge:** module pages are now first-class top-level routes, `/<module-id>` (`/signage`), not `/modules/<id>`. Platform-owned top-level names are reserved, and Core enforces the reservation. The second commit has the change; ADR 0057 has been rewritten to match. ## Why this first Every later Stage 1 page needs a stable address: Settings, Account, Settings → Displays → Add display, a module's own pages in the sidebar, and returning to the workflow a Settings link came from. Until now the Dashboard had one URL. `apps/api` served the page only at `/`, both reference proxies forwarded only `/`, and the open module lived in React state. A refresh always went back to the first module, nothing could be bookmarked, and Back left the Dashboard. (DESIGN.md §2.7, §5; ROADMAP §5.2.) ## URL model ```text / Overview (the landing page) /settings[/…] Settings (reserved; pages come later) /account[/…] Account (reserved; pages come later) /<module-id> a module's page: /signage, /calendar, /people /<module-id>/<page>[/…] reserved for a module's own pages: /signage/timelines ``` "Module" never appears in a user-facing address. ## What people will see - The Dashboard opens on an **Overview** at `/`, not on the first module. The Overview says honestly that it has nothing to show yet. - Each module has its own top-level address. Refresh, bookmarks, shared links, and Back/Forward all land on the page that was open. - Navigation items are real links. Ctrl/⌘-click or middle-click opens a page in a new tab. The open page is marked with `aria-current="page"` and shown by weight as well as colour. - An installed module that is **disabled** (or blocked) still has its address, and shows *This page isn't available* while loading nothing. An address that is not a Dashboard page at all, such as `/definitely-not-a-real-module`, gets the API's normal sanitized `404`. - A deep link opened while signed out shows sign-in at that address, then opens its own page after sign-in. ADR 0034 is unchanged: no return parameter, no redirect. - After an in-place page change, focus moves to the page's `<main>`, and the browser tab is named after the page. It never says "Ekklesia". ## How ### Reserved top-level names (Core) `RESERVED_MODULE_IDS` in `core/src/modules/reserved-ids.ts` is the set the platform actually serves at the top level today: `api`, `assets`, `display`, `settings`, and `account`. Discovery refuses a module that declares one with a new issue code, **`MODULE_ID_RESERVED`**. That covers API startup, `pnpm modules:validate`, and Module Package verification. Look-alikes such as `settings-sync` and `displays` are fine. Tests in `apps/api` hold the set to reality: - every top-level segment the composed API registers is reserved, or is a module page; - every display host and Dashboard script path constant starts with a reserved name; - every Dashboard page the shell interprets is reserved. A new top-level platform route therefore fails a test until it is reserved. ### API `createDashboardHostRoutes` serves the same frozen bytes, `ETag`, and security headers at: - `/`; - `/settings` and `/account`, each with everything below it; - `/<module-id>` with everything below it, for **each discovered module that declares a Dashboard UI** (enabled or not). The set is fixed at startup, like every other module-derived route. Not-installed modules, display-only modules, look-alikes (`/signages`, `/settings-old`), the old `/modules/…` form, other methods, and every other path stay the sanitized `404`. Composition refuses a module page that is not a valid id, is reserved, or is duplicated. ### Shell - `@ekklesia/shell/dashboard-routes` exports the Dashboard's platform pages (`settings`, `account`) and the path parser. The server and the router read the same list. - A small host-owned `HostHistory` replaces the selected-module state. The entry point stays the only file that touches `window`. - No routing library. ### Proxies A proxy can't know which modules are installed, so both reference Caddyfiles gain a `@dashboardPage` matcher that forwards every top-level path shaped like a module id: `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`. The API decides between the page and a `404`. Anything else, such as `/favicon.ico` or `/.well-known/…`, is still refused by the proxy. In the split topology, the Dashboard hostname still refuses `/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. Installing or removing a module needs no proxy change. Both files were adapted with real `caddy:2`. ### Subpages, later `/<module-id>/<page>` is already served by the API and forwarded by the proxies. The future module page contract (manifest, SDK, and shell) therefore needs no routing or deployment change. Until it exists, those addresses show the shell's *Page not found*. ## Points to review - **New module rule:** a module may not be called `account`, `api`, `assets`, `display`, or `settings`. No known module is. One that was would fail discovery and must be renamed. - **Installed-module disclosure:** serving `/<module-id>` before sign-in lets an anonymous request learn whether a module with a Dashboard UI is installed (`200` vs `404`). This is the same disclosure the display host already makes at `/display/<id>` (ADR 0033), and it is recorded in the ADR. The alternative, a page at every module-shaped path, is exactly the "arbitrary path becomes a Dashboard page" this PR rejects. - **No contracts change:** the reservation is enforced by Core's discovery, not by the public `@ekklesia/contracts` manifest grammar. It is the Ekklesia host's URL space, and a standalone host has no Settings page to protect. ## ⚠️ Operator impact An installation with its **own** proxy must forward module-shaped top-level paths using the matcher above. Without that, the Dashboard still opens at `/`, but refreshing any other page returns the proxy's `404`. Installations using the reference Caddyfiles copy the new examples. The deployment guide covers this in *Upgrading* and in the own-proxy checklist, and tells operators not to list module ids. ## Stage 0 behaviour preserved - The display host (`/display/*`, its script, and its service worker) is untouched, and paired displays are unaffected. `display` is now a reserved module id. - Emailed verification and reset links still land on `/` with a query. - Module mounting, the keyed frame (stale-load protection), session-expiry handling, and sign-out cleanup are unchanged and still covered by their existing tests. ## Tests - **Core:** `module-discovery.test.ts` checks that each reserved id is refused with `MODULE_ID_RESERVED` and that look-alikes are accepted. - **Shell:** - `dashboard-routes.test.ts`: top-level module pages, platform pages never read as modules, sub-paths not-found for now, encoding, canonical spelling. - `host-history.test.ts`. - `host-routing.test.tsx`: landing, deep links, unavailable and not-found pages, trailing-slash canonicalization, sign-in at a deep link, real links, modified clicks, Back/Forward, focus, titles, no `/modules/` anywhere. - Existing shell tests now open their module's top-level address. - **API:** `dashboard-host-routes.test.ts` covers: - the same page, ETag, and headers at platform pages and installed (including disabled) modules' pages and sub-paths; - a `304` on a deep link; - sanitized `404` for unknown names, display-only and uninstalled modules, look-alikes, and `/modules/…`; - other methods refused; - composition refusing reserved, invalid, or duplicate module pages; - the reserved-set-versus-registered-routes checks described above. - **Deploy config:** `tests/deploy.test.ts` checks both Caddyfiles' matchers, and tests the regular expression itself against paths it must and must not forward. - **Deploy smoke:** - Through the real proxy: platform deep links are the same page; unknown names, look-alikes, and `/modules/example` are the API's sanitized `404`. - A module's address is a `404` before install, the page while installed, **still the page while disabled**, a `404` again once removed, and the page again after reinstall. - In a real browser, opening the module puts `/<id>` in the address bar, and reloading it reopens the same module. - `module_check` gained a separate page expectation, because "not running" (disabled) and "not installed" are different here. ### Run locally All of these ran on the pushed commit: - `pnpm verify`: passed. - `pnpm infrastructure:config` and `pnpm deploy:config`: passed. - `pnpm test:migrations:integration`: passed, 15 files / 401 tests. - `bash scripts/smoke-deploy.sh` (same-origin): passed. - `EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh`: passed. - `caddy adapt` for both Caddyfiles: matchers and handle order as intended. - Not run: `test:package-release` and `test:package-publication`. No public `@ekklesia/*` package changed; Core, the API, and the shell are private. ## Not in this PR (later Stage 1 slices, per ADR 0057 *Deferred*) - A module's own sub-pages and their manifest/SDK contract. - Return-to-origin for Settings deep links. - The Settings and Account pages themselves. - The collapsible sidebar and mobile drawer. - Overview cards. - Branding: the static `<title>` and the header's hard-coded name are unchanged here. ## Issues filed while doing this - #101: ADR 0046's startup-configured branding conflicts with DESIGN.md's Settings-managed branding. - #102: CLAUDE.md still forbids starting Stage 1 work. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ekklesia-bot added 1 commit 2026-10-14 13:11:14 +00:00
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
9afe9376c4
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]>
Zayin_Admin added 1 commit 2026-10-14 13:42:36 +00:00
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
ba60f70835
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]>
ekklesia-bot changed title from feat(shell): give every Dashboard page a real URL (Stage 1, ADR 0057) to feat(shell): give every Dashboard page a real URL, with modules at /<module-id> (Stage 1, ADR 0057) 2026-10-14 13:42:41 +00:00
Joey approved these changes 2026-10-14 22:07:14 +00:00
Joey merged commit f3b7e19651 into main 2026-10-14 22:07:20 +00:00
Joey deleted branch feat/dashboard-url-routing 2026-10-14 22:07:20 +00:00
Sign in to join this conversation.
No Reviewers
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#103