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
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:
co-authored by
Claude Opus 5.5
parent
9afe9376c4
commit
ba60f70835
@@ -342,7 +342,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
setState(resolved);
|
||||
}, []);
|
||||
|
||||
// A non-canonical spelling of a real page (`/modules/example/`) is corrected in place, adding no
|
||||
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
|
||||
// history entry, so what is bookmarked or shared next is the one canonical address.
|
||||
useEffect(() => {
|
||||
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
/**
|
||||
* The Dashboard's URL space (ADR 0057): which paths are Dashboard pages, and what each one means.
|
||||
* The Dashboard's URL space (ADR 0057): what each Dashboard path 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.
|
||||
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
|
||||
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
|
||||
*
|
||||
* Module pages and platform pages share one top-level namespace:
|
||||
*
|
||||
* ```text
|
||||
* / Overview
|
||||
* /settings[/…] Settings
|
||||
* /account[/…] the account area
|
||||
* /<module-id>[/…] a module's page, and later its own sub-pages
|
||||
* ```
|
||||
*
|
||||
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
|
||||
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
|
||||
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
|
||||
* root path never becomes one.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
|
||||
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
|
||||
* reserved module id in Core; `apps/api` tests hold the two lists together.
|
||||
*/
|
||||
export const DASHBOARD_PAGE_PREFIXES = Object.freeze([
|
||||
"/modules",
|
||||
"/settings",
|
||||
"/account",
|
||||
] as const);
|
||||
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["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 =
|
||||
@@ -30,16 +36,7 @@ export type DashboardRoute =
|
||||
/** 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}/`),
|
||||
)
|
||||
);
|
||||
}
|
||||
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
|
||||
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
@@ -53,12 +50,15 @@ function decodeSegment(segment: string): string | 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.
|
||||
* A single top-level segment that is not a platform page names a module's page. The 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 `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), 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. Settings and Account pages are likewise `not-found` until they exist. 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 === "/") {
|
||||
@@ -69,11 +69,12 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
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 };
|
||||
}
|
||||
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
|
||||
if (first === undefined || platformPages.has(first)) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
if (meaningful.length === 1) {
|
||||
return { kind: "module", moduleId: first };
|
||||
}
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
@@ -84,5 +85,5 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
if (route.kind === "overview") {
|
||||
return "/";
|
||||
}
|
||||
return `/modules/${encodeURIComponent(route.moduleId)}`;
|
||||
return `/${encodeURIComponent(route.moduleId)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user