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

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]>
This commit is contained in:
ekklesia
2026-10-14 08:11:02 -05:00
co-authored by Claude Opus 5.5
parent 44430b5d59
commit 9afe9376c4
31 changed files with 1634 additions and 120 deletions
+88
View File
@@ -0,0 +1,88 @@
/**
* The Dashboard's URL space (ADR 0057): which paths are Dashboard pages, and what each one means.
*
* Framework-free and free of browser globals, because two programs read it. The shell parses the
* address bar with it, and `apps/api` serves the Dashboard page at exactly the namespaces it lists,
* so a refreshed or bookmarked deep link reaches the shell instead of a `404`. One list, imported by
* both, is what keeps the server from serving a path the shell cannot interpret, or the shell from
* linking to a path the server would refuse.
*/
/**
* Every namespace the Dashboard owns besides `/` itself. Reserved whole, including paths no page uses
* yet, so that adding a Settings or Account page never needs another server or proxy change.
*
* None of these overlaps a namespace something else owns: `/api` (the API), `/display` (the display
* host), and `/assets` (both hosts' scripts).
*/
export const DASHBOARD_PAGE_PREFIXES = Object.freeze([
"/modules",
"/settings",
"/account",
] as const);
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
| { readonly kind: "module"; readonly moduleId: string }
| { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
/** Whether the Dashboard page is served at this path: the root, or anything inside a reserved
* namespace. Says nothing about whether a page exists there; the shell decides that. */
export function isDashboardPagePath(pathname: string): boolean {
return (
pathname === "/" ||
DASHBOARD_PAGE_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
)
);
}
function decodeSegment(segment: string): string | undefined {
try {
const decoded = decodeURIComponent(segment);
return decoded.length > 0 ? decoded : undefined;
} catch {
return undefined;
}
}
/**
* Reads a path into a route.
*
* `/modules/<id>` names a module's page. The module id is only decoded here, never validated against
* a pattern: the shell compares it with the navigation the server resolved, and an id that matches
* no entry is an unavailable module, not a malformed URL. Everything below `/modules/<id>/` belongs
* to that module's own pages, which need a contract that does not exist yet, so it is `not-found`
* for now rather than silently showing the module's first page. One trailing slash is tolerated, as
* a person typing a URL expects; the shell then replaces it with the canonical spelling.
*/
export function parseDashboardPath(pathname: string): DashboardRoute {
if (pathname === "/") {
return { kind: "overview" };
}
const segments = pathname.split("/");
if (segments[0] !== "") {
return { kind: "not-found" };
}
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
if (meaningful.length === 2 && meaningful[0] === "modules") {
const moduleId = decodeSegment(meaningful[1] ?? "");
if (moduleId !== undefined) {
return { kind: "module", moduleId };
}
}
return { kind: "not-found" };
}
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */
export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") {
return "/";
}
return `/modules/${encodeURIComponent(route.moduleId)}`;
}