CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
218 lines
8.9 KiB
TypeScript
218 lines
8.9 KiB
TypeScript
/**
|
|
* 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 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
|
|
* /settings/<section> a platform Settings section: general, branding, displays, system
|
|
* /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates
|
|
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
|
|
* /account your account
|
|
* /account/<section> a personal settings page: appearance
|
|
* /<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.
|
|
*/
|
|
|
|
/**
|
|
* 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_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
|
|
|
|
/**
|
|
* The platform's own Settings sections, in their navigation order (ADR 0061). Every other name under
|
|
* `/settings/` is a module's settings, at `/settings/<module-id>`. Module lifecycle — finding,
|
|
* installing, turning on and off, updating — is Discover, at `/settings/discover/…`; configuring a
|
|
* module is its own `/settings/<module-id>`. Each section name is also a reserved module id in Core,
|
|
* so no module can take one, and `apps/api` tests hold the two lists together.
|
|
*/
|
|
export const SETTINGS_SECTIONS = Object.freeze([
|
|
"general",
|
|
"branding",
|
|
"displays",
|
|
"discover",
|
|
"system",
|
|
] as const);
|
|
|
|
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
|
|
|
/** The sections that are one page each. Discover is a group of views instead. */
|
|
export type SettingsPageSection = Exclude<SettingsSection, "discover">;
|
|
|
|
/**
|
|
* The account area's personal settings pages, in navigation order (ADR 0062). Personal, never
|
|
* church administration: that is Settings. They live beneath `/account/`, so none can collide with a
|
|
* module id and none is reserved.
|
|
*/
|
|
export const ACCOUNT_SECTIONS = Object.freeze(["appearance"] as const);
|
|
|
|
export type AccountSection = (typeof ACCOUNT_SECTIONS)[number];
|
|
|
|
/**
|
|
* Discover's views, in navigation order: Browse (find and install modules), Installed (see and
|
|
* manage installed modules), and Updates (module and platform updates). Browse is where Discover
|
|
* opens. They live beneath `/settings/discover/`, so none can collide with a module id.
|
|
*/
|
|
export const DISCOVER_VIEWS = Object.freeze(["browse", "installed", "updates"] as const);
|
|
|
|
export type DiscoverView = (typeof DISCOVER_VIEWS)[number];
|
|
|
|
/** 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: "settings" }
|
|
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
|
|
| { readonly kind: "discover"; readonly view: DiscoverView }
|
|
| { readonly kind: "module-settings"; readonly moduleId: string }
|
|
| { readonly kind: "account" }
|
|
| { readonly kind: "account-section"; readonly section: AccountSection }
|
|
| { 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" }>;
|
|
|
|
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
|
|
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
|
|
|
|
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
|
|
const accountSections: ReadonlySet<string> = new Set(ACCOUNT_SECTIONS);
|
|
|
|
function isSettingsPageSection(value: string): value is SettingsPageSection {
|
|
return value !== "discover" && settingsSections.has(value);
|
|
}
|
|
|
|
function isDiscoverView(value: string): value is DiscoverView {
|
|
return discoverViews.has(value);
|
|
}
|
|
|
|
function isAccountSection(value: string): value is AccountSection {
|
|
return accountSections.has(value);
|
|
}
|
|
|
|
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.
|
|
*
|
|
* 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 === "/") {
|
|
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);
|
|
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
|
|
if (first === "settings") {
|
|
return parseSettingsPath(meaningful.slice(1));
|
|
}
|
|
if (first === "account") {
|
|
return parseAccountPath(meaningful.slice(1));
|
|
}
|
|
if (first === undefined || platformPages.has(first)) {
|
|
return { kind: "not-found" };
|
|
}
|
|
if (meaningful.length === 1) {
|
|
return { kind: "module", moduleId: first };
|
|
}
|
|
return { kind: "not-found" };
|
|
}
|
|
|
|
/**
|
|
* Reads what follows `/settings`. One more segment is a platform section or, failing that, a
|
|
* module's settings. `discover` is entered at Browse: `/settings/discover` reads as Browse, whose
|
|
* canonical spelling is `/settings/discover/browse`, so the shell respells it in place and there is
|
|
* one Browse address rather than two. Anything deeper is reserved for later Settings pages and is
|
|
* `not-found` until they exist, rather than silently showing their parent.
|
|
*/
|
|
function parseSettingsPath(rest: readonly string[]): DashboardRoute {
|
|
if (rest.length === 0) {
|
|
return { kind: "settings" };
|
|
}
|
|
const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]);
|
|
if (name === undefined) {
|
|
return { kind: "not-found" };
|
|
}
|
|
if (name === "discover") {
|
|
if (rest.length === 1) {
|
|
return { kind: "discover", view: "browse" };
|
|
}
|
|
const view = rest[1] === undefined ? undefined : decodeSegment(rest[1]);
|
|
return rest.length === 2 && view !== undefined && isDiscoverView(view)
|
|
? { kind: "discover", view }
|
|
: { kind: "not-found" };
|
|
}
|
|
if (rest.length !== 1) {
|
|
return { kind: "not-found" };
|
|
}
|
|
return isSettingsPageSection(name)
|
|
? { kind: "settings-section", section: name }
|
|
: { kind: "module-settings", moduleId: name };
|
|
}
|
|
|
|
/** Reads what follows `/account`: the account page itself, or one of its personal settings pages.
|
|
* Anything else is `not-found` until it exists. */
|
|
function parseAccountPath(rest: readonly string[]): DashboardRoute {
|
|
if (rest.length === 0) {
|
|
return { kind: "account" };
|
|
}
|
|
const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]);
|
|
return rest.length === 1 && name !== undefined && isAccountSection(name)
|
|
? { kind: "account-section", section: name }
|
|
: { 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 {
|
|
switch (route.kind) {
|
|
case "overview":
|
|
return "/";
|
|
case "module":
|
|
return `/${encodeURIComponent(route.moduleId)}`;
|
|
case "settings":
|
|
return "/settings";
|
|
case "settings-section":
|
|
return `/settings/${route.section}`;
|
|
case "discover":
|
|
return `/settings/discover/${route.view}`;
|
|
case "module-settings":
|
|
return `/settings/${encodeURIComponent(route.moduleId)}`;
|
|
case "account":
|
|
return "/account";
|
|
case "account-section":
|
|
return `/account/${route.section}`;
|
|
}
|
|
}
|