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
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:
co-authored by
Claude Opus 5.5
parent
44430b5d59
commit
9afe9376c4
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user