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.
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)
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]>
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
First Stage 1 slice: the Dashboard gets real URLs. The decision is recorded in ADR 0057.
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/apiserved 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
"Module" never appears in a user-facing address.
What people will see
/, not on the first module. The Overview says honestly that it has nothing to show yet.aria-current="page"and shown by weight as well as colour./definitely-not-a-real-module, gets the API's normal sanitized404.<main>, and the browser tab is named after the page. It never says "Ekklesia".How
Reserved top-level names (Core)
RESERVED_MODULE_IDSincore/src/modules/reserved-ids.tsis the set the platform actually serves at the top level today:api,assets,display,settings, andaccount. 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 assettings-syncanddisplaysare fine.Tests in
apps/apihold the set to reality:A new top-level platform route therefore fails a test until it is reserved.
API
createDashboardHostRoutesserves the same frozen bytes,ETag, and security headers at:/;/settingsand/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 sanitized404. Composition refuses a module page that is not a valid id, is reserved, or is duplicated.Shell
@ekklesia/shell/dashboard-routesexports the Dashboard's platform pages (settings,account) and the path parser. The server and the router read the same list.HostHistoryreplaces the selected-module state. The entry point stays the only file that toucheswindow.Proxies
A proxy can't know which modules are installed, so both reference Caddyfiles gain a
@dashboardPagematcher 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 a404. Anything else, such as/favicon.icoor/.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 realcaddy: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
account,api,assets,display, orsettings. No known module is. One that was would fail discovery and must be renamed./<module-id>before sign-in lets an anonymous request learn whether a module with a Dashboard UI is installed (200vs404). 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.@ekklesia/contractsmanifest 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's404. 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
/display/*, its script, and its service worker) is untouched, and paired displays are unaffected.displayis now a reserved module id./with a query.Tests
module-discovery.test.tschecks that each reserved id is refused withMODULE_ID_RESERVEDand that look-alikes are accepted.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.dashboard-host-routes.test.tscovers:304on a deep link;404for unknown names, display-only and uninstalled modules, look-alikes, and/modules/…;tests/deploy.test.tschecks both Caddyfiles' matchers, and tests the regular expression itself against paths it must and must not forward./modules/exampleare the API's sanitized404.404before install, the page while installed, still the page while disabled, a404again once removed, and the page again after reinstall./<id>in the address bar, and reloading it reopens the same module.module_checkgained 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:configandpnpm 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 adaptfor both Caddyfiles: matchers and handle order as intended.test:package-releaseandtest: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)
<title>and the header's hard-coded name are unchanged here.Issues filed while doing this
🤖 Generated with Claude Code
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)