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

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:
ekklesia
2026-10-14 08:42:34 -05:00
co-authored by Claude Opus 5.5
parent 9afe9376c4
commit ba60f70835
26 changed files with 745 additions and 334 deletions
+8 -6
View File
@@ -908,12 +908,14 @@ module — Signage included — still do not exist, and nothing in Phase 8 claim
Stage 1 begins with **real Dashboard URLs** Stage 1 begins with **real Dashboard URLs**
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The ([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
Dashboard opens on an **Overview** at `/`, and each module's page has its own address, Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
`/modules/<module-id>`, so a refresh, a bookmark, and a shared link land on the page that was open address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
and Back and Forward walk the Dashboard's own pages. The API serves the same Dashboard page at `/` and and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
throughout the reserved `/modules`, `/settings`, and `/account` namespaces, and both reference proxies The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
forward them. A module that is not in the server's navigation — disabled, removed, or with no UI — each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
shows a host-owned *This page isn't available* rather than loading anything. A module's own the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
*This page isn't available* rather than loading anything. A module's own
sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings
link came from are later Stage 1 work. link came from are later Stage 1 work.
+12 -4
View File
@@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// own entirely. See `./health-routes.js`. // own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health)); app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They // The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See // page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
// `./dashboard-host-routes.js`. // `/<module-id>`. They grant no authority and resolve no principal; the page asks
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets)); // `/api/v1/principal` itself. See `./dashboard-host-routes.js`.
app.route(
"/",
createDashboardHostRoutes(options.dashboardHostAssets, {
modulePages: options.moduleLifecycle.snapshot.discoveredModules
.filter((module) => module.entrypoints.ui !== undefined)
.map((module) => module.manifest.id),
}),
);
app.route( app.route(
"/", "/",
+57 -18
View File
@@ -1,15 +1,19 @@
import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes"; import { moduleIdSchema } from "@ekklesia/contracts";
import { isReservedModuleId } from "@ekklesia/core/modules";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import { Hono } from "hono"; import { Hono } from "hono";
import { etagMatches } from "./conditional-request.js"; import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
/** /**
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API * The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
* serves: the page answers at `/` and inside the Dashboard's reserved namespaces, which the shell * serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
* exports so this server and the shell's router read one list, and the script lives beside, never * this server and the shell's router read one list), and at each installed module's `/<module-id>`.
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under * Every one of those first segments is either a reserved platform name or a module id Core accepted,
* `/api/v1/modules/<module-id>`, and every emailed verification or reset link still lands on `/` with * and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
* a query (ADR 0038). * `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
* lands on `/` with a query (ADR 0038).
*/ */
export const DASHBOARD_HOST_PAGE_PATH = "/"; export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
@@ -87,22 +91,57 @@ function fixedAsset(
}); });
} }
export interface DashboardHostRouteOptions {
/**
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
*/
readonly modulePages: readonly string[];
}
/** Each module page must be a real module id that names no platform namespace, exactly once.
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
const seen = new Set<string>();
for (const moduleId of modulePages) {
if (
!moduleIdSchema.safeParse(moduleId).success ||
isReservedModuleId(moduleId) ||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
seen.has(moduleId)
) {
throw new DashboardHostConfigurationError();
}
seen.add(moduleId);
}
return [...seen];
}
/** /**
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape. * default same-origin shape.
* *
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken * Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
* from the request, and never the source map. The page answers at `/` and anywhere inside a reserved * from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
* Dashboard namespace — `/settings/displays/add` as much as `/settings` — with the **same** bytes and * platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
* the same validator, because the path selects a page inside the shell rather than a file here; that * module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
* is what lets a refreshed or bookmarked deep link stay where it was. Any other path — `/index.html`, * server change later) — always with the **same** bytes and the same validator, because the path
* `/app.js`, the `.map`, a look-alike such as `/settings-old`, a traversal spelling — has no route * selects a page inside the shell rather than a file here; that is what lets a refreshed or
* and falls through to the application's ordinary sanitized `404`. Like the display host's code, * bookmarked deep link stay where it was.
* these bytes grant no authority: the page renders the sign-in form until `/api/v1/principal` says *
* otherwise. * A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the
* application's ordinary sanitized `404`. Like the display host's code, these bytes grant no
* authority: the page renders the sign-in form until `/api/v1/principal` says otherwise.
*/ */
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono { export function createDashboardHostRoutes(
assets: DashboardHostAssets,
options: DashboardHostRouteOptions,
): Hono {
const snapshot = snapshotAssets(assets); const snapshot = snapshotAssets(assets);
const modulePages = validatedModulePages(options.modulePages);
const routes = new Hono(); const routes = new Hono();
const page = (ifNoneMatch: string | undefined): Response => const page = (ifNoneMatch: string | undefined): Response =>
@@ -115,9 +154,9 @@ export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
); );
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match"))); routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
for (const prefix of DASHBOARD_PAGE_PREFIXES) { for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
routes.get(prefix, (c) => page(c.req.header("if-none-match"))); routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
routes.get(`${prefix}/*`, (c) => page(c.req.header("if-none-match"))); routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
} }
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) => routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
+172 -31
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
type ModuleRouteHandler, type ModuleRouteHandler,
parseModuleManifest,
parsePermissionId, parsePermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
@@ -13,9 +14,13 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
isReservedModuleId,
type LegacyModuleRuntimeServices, type LegacyModuleRuntimeServices,
type ModuleDatabase, type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_MODULE_IDS,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -33,12 +38,23 @@ import {
DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError, DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js"; } from "../src/dashboard-host-routes.js";
import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes"; import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js"; import {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
} from "../src/module-display-path.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
} from "../src/module-display-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -79,9 +95,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
} }
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
},
id,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function buildApp( function buildApp(
options: { options: {
readonly assets?: DashboardHostAssets; readonly assets?: DashboardHostAssets;
readonly configuration?: ModuleLifecycleConfiguration;
readonly discovered?: readonly DiscoveredModule[];
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[]; readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {}, } = {},
) { ) {
@@ -101,7 +148,10 @@ function buildApp(
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]), moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
@@ -195,24 +245,37 @@ describe("GET / (the Dashboard page)", () => {
}); });
describe("Dashboard deep links (ADR 0057)", () => { describe("Dashboard deep links (ADR 0057)", () => {
const installed = () => [
discoveredModule("signage", { ui: true }),
discoveredModule("calendar", { ui: true }),
discoveredModule("retired", { ui: true }),
// A display-only module has no Dashboard page (ADR 0033).
discoveredModule("lobby-screen", { ui: false }),
];
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
const deepLinks = [ const deepLinks = [
"/modules",
"/modules/",
"/modules/signage",
"/modules/signage/timelines/a1b2",
"/modules/not-installed",
"/settings", "/settings",
"/settings/",
"/settings/displays/add", "/settings/displays/add",
"/account", "/account",
"/account/appearance", "/account/appearance",
"/signage",
"/signage/",
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
"/signage/timelines",
"/signage/timelines/a1b2",
"/calendar",
"/retired",
]; ];
it("reserves exactly the namespaces the shell interprets, from the shell's own list", () => { it("serves the shell's own platform pages, from the shell's own list", () => {
expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/settings", "/account"]); expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
}); });
it("serves the identical page, validator, and security headers anywhere inside them, so a refresh stays put", async () => { it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
const { app } = buildApp(); const { app } = buildApp({ configuration, discovered: installed() });
for (const path of deepLinks) { for (const path of deepLinks) {
const response = await app.request(path); const response = await app.request(path);
@@ -227,41 +290,119 @@ describe("Dashboard deep links (ADR 0057)", () => {
}); });
it("revalidates a deep link with the page's one validator", async () => { it("revalidates a deep link with the page's one validator", async () => {
const { app } = buildApp(); const { app } = buildApp({ discovered: installed() });
const response = await app.request("/settings/displays", { for (const path of ["/settings/displays", "/signage"]) {
headers: { "If-None-Match": PAGE_ETAG }, const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
expect(response.status, path).toBe(304);
expect(await response.text(), path).toBe("");
expectPageSecurityHeaders(response);
}
}); });
expect(response.status).toBe(304); it("never turns an arbitrary top-level path into a Dashboard page", async () => {
expect(await response.text()).toBe(""); const { app } = buildApp({ configuration, discovered: installed() });
expectPageSecurityHeaders(response);
for (const path of [
"/definitely-not-a-real-module",
"/definitely-not-a-real-module/timelines",
// Installed, but with no Dashboard UI.
"/lobby-screen",
// Look-alikes of real pages.
"/signages",
"/signage-old",
"/Signage",
"/settings-old",
"/accounts",
// The former implementation prefix is not a namespace at all.
"/modules",
"/modules/signage",
]) {
await expectSanitizedNotFound(await app.request(path), path);
}
});
it("serves a module's page only while that module is installed", async () => {
const { app } = buildApp({ discovered: [] });
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
expect((await app.request("/settings")).status).toBe(200);
}); });
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => { it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
const { app } = buildApp(); const { app } = buildApp({ discovered: installed() });
for (const path of [ for (const path of [
"/modules/app.js", "/signage/app.js",
"/settings/assets/ekklesia-dashboard/app.js", "/settings/assets/ekklesia-dashboard/app.js",
"/account/index.html", "/account/index.html",
]) { ]) {
const response = await app.request(path); expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
expect(await response.text(), path).toBe(PAGE_HTML);
} }
}); });
it("claims no look-alike prefix and no other method", async () => { it("answers no other method at a Dashboard page", async () => {
const { app } = buildApp(); const { app } = buildApp({ discovered: installed() });
for (const path of ["/modulesx", "/settings-old", "/accounts", "/Modules/signage"]) { for (const path of ["/settings/displays", "/signage"]) {
await expectSanitizedNotFound(await app.request(path), path);
}
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
await expectSanitizedNotFound( await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
await app.request("/settings/displays", { method }), }
`${method} /settings/displays`, }
});
it("refuses at composition a module page that would shadow a platform namespace", () => {
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
expect(
() =>
createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }),
JSON.stringify(modulePages),
).toThrow(DashboardHostConfigurationError);
}
});
});
describe("the platform's reserved top-level names (ADR 0057)", () => {
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
const topLevel = new Set(
app.routes
.map((route) => route.path.split("/")[1] ?? "")
.filter((segment) => segment !== "" && segment !== "*"),
); );
topLevel.delete("signage");
// This composition publishes no display, so the display host's routes are absent here; they
// are checked through their own path constants below.
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
for (const segment of topLevel) {
expect(isReservedModuleId(segment), segment).toBe(true);
}
});
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
for (const path of [
MODULE_DISPLAY_PAGE_PREFIX,
DISPLAY_SERVICE_WORKER_PATH,
DISPLAY_HOST_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
]) {
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
}
expect([...RESERVED_MODULE_IDS].sort()).toEqual([
"account",
"api",
"assets",
"display",
"settings",
]);
});
it("include every Dashboard platform page the shell interprets", () => {
for (const page of DASHBOARD_PLATFORM_PAGES) {
expect(RESERVED_MODULE_IDS, page).toContain(page);
} }
}); });
}); });
+14
View File
@@ -9,6 +9,7 @@ import {
safeParseModuleManifest, safeParseModuleManifest,
semanticVersionSchema, semanticVersionSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { isReservedModuleId } from "./reserved-ids.js";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_ENTRYPOINT_INVALID" | "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING" | "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE" | "MODULE_ID_DUPLICATE"
| "MODULE_ID_RESERVED"
| "MODULE_MANIFEST_INVALID" | "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING" | "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID" | "MODULE_PACKAGE_INVALID"
@@ -614,6 +616,18 @@ async function scanModuleDirectory(
}); });
} }
if (isReservedModuleId(manifest.id)) {
// A module's Dashboard page is `/<module-id>` (ADR 0057), in the same URL space as the
// platform's own top-level namespaces.
issues.push({
code: "MODULE_ID_RESERVED",
message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "id",
});
}
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) { if (!artifacts) {
return undefined; return undefined;
+1
View File
@@ -13,6 +13,7 @@ export * from "./module-package-host.js";
export * from "./module-package-verification.js"; export * from "./module-package-verification.js";
export * from "./permissions.js"; export * from "./permissions.js";
export * from "./realtime.js"; export * from "./realtime.js";
export * from "./reserved-ids.js";
export * from "./runtime.js"; export * from "./runtime.js";
export * from "./schema-discovery.js"; export * from "./schema-discovery.js";
export * from "./ui-publication.js"; export * from "./ui-publication.js";
+33
View File
@@ -0,0 +1,33 @@
/**
* Top-level names the platform owns, which therefore can never be a module id (ADR 0057).
*
* Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own
* top-level namespaces in one URL space. A module called `settings` would collide with the
* Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the
* platform serves today, not a guess at what it might want later:
*
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
* display descriptors, and realtime (`/api/…`);
* - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`,
* `/assets/ekklesia-display/…`);
* - `display` — the display host's pages and service worker (`/display/…`);
* - `settings` and `account` — the Dashboard's own Settings and account areas.
*
* `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the
* shell interprets, so a new top-level platform route cannot be added without reserving it here.
* Reserving a name is a breaking change for any installation with a module of that id, so the set
* grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze([
"account",
"api",
"assets",
"display",
"settings",
] as const);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
return reserved.has(moduleId);
}
+48
View File
@@ -4,8 +4,10 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import {
discoverModules, discoverModules,
isReservedModuleId,
ModuleDiscoveryError, ModuleDiscoveryError,
type ModuleDiscoveryIssueCode, type ModuleDiscoveryIssueCode,
RESERVED_MODULE_IDS,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
interface ModuleFixtureOptions { interface ModuleFixtureOptions {
@@ -271,6 +273,52 @@ describe("module discovery", () => {
); );
}); });
it("rejects a module whose id is one of the platform's reserved top-level names (ADR 0057)", async () => {
// Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a
// platform-owned top-level namespace would collide with the API, the display host, a host
// script, or a Dashboard page.
expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]);
expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true);
for (const id of RESERVED_MODULE_IDS) {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, id);
const error = await discoveryError(modulesDirectory);
expect(error.issues, id).toEqual([
expect.objectContaining({
code: "MODULE_ID_RESERVED",
moduleDirectory: id,
moduleId: id,
path: "id",
}),
]);
}
});
it("accepts ids that merely resemble a reserved name", async () => {
const modulesDirectory = await createModulesDirectory();
for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) {
await createModule(modulesDirectory, id);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(modules.map((module) => module.manifest.id).sort()).toEqual([
"api-docs",
"displays",
"my-account",
"settings-sync",
]);
expect(isReservedModuleId("settings")).toBe(true);
expect(isReservedModuleId("settings-sync")).toBe(false);
});
it("enforces platform and Module SDK compatibility", async () => { it("enforces platform and Module SDK compatibility", async () => {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "future", { await createModule(modulesDirectory, "future", {
+16 -5
View File
@@ -41,11 +41,16 @@
} }
{$EKKLESIA_HOSTNAME} { {$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at / and at its own pages (/modules, /settings, and # Everything the API serves: the Dashboard at /, the API, display pages and their service
# /account, each with everything below it, so a refreshed deep link stays put), the API, display # worker, and the Dashboard and display host scripts.
# pages and their service worker, and the Dashboard and display host scripts. Any other path is @ekklesia path / /api/* /display/* /assets/*
# refused here.
@ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/* # The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# Any other path is refused here.
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
# Module realtime is Server-Sent Events: flush every event immediately. # Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/* @realtime path /api/v1/module-realtime/*
@@ -65,6 +70,12 @@
} }
} }
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle { handle {
respond 404 respond 404
} }
+20 -6
View File
@@ -45,13 +45,21 @@
header_up -True-Client-IP header_up -True-Client-IP
} }
# The Dashboard: its page — at `/` and at every Dashboard page (/modules, /settings, and /account, # The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land
# each with everything below it), so a refreshed deep link stays put — and its one script, nothing # on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one
# else. Emailed verification and reset links land on `/` with a query. `/api/*` is refused here on # home, the API hostname, and the Dashboard can never fall back to a same-origin API.
# purpose, so the session cookie has exactly one home, the API hostname, and the Dashboard can never
# fall back to a same-origin API.
{$EKKLESIA_DASHBOARD_HOSTNAME} { {$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/* @dashboard path / /assets/ekklesia-dashboard/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# The API's own namespaces stay refused on this hostname.
@dashboardPage {
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
not path /api /api/* /display /display/* /assets /assets/*
}
handle @dashboard { handle @dashboard {
reverse_proxy api:3000 { reverse_proxy api:3000 {
@@ -59,6 +67,12 @@
} }
} }
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle { handle {
respond 404 respond 404
} }
+12 -9
View File
@@ -242,10 +242,12 @@ Module storage is installation state: back it up with the database and the bucke
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`. 5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with **Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
the new `Caddyfile.example` (or `Caddyfile.split.example`). The Dashboard's matcher gains `/modules`, the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
`/settings`, `/account` and everything below each; without them the Dashboard still opens at `/`, and `handle` block that forward every top-level path shaped like a module id — `/settings`,
but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the proxy after `/account`, and each installed module's page such as `/signage` — to the API, which answers with
editing it. the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
proxy after editing it. Installing or removing a module needs no proxy change.
### Rolling back ### Rolling back
@@ -269,7 +271,7 @@ The same image serves both; only configuration differs.
| Hostname | Serves | Refuses (`404`) | | Hostname | Serves | Refuses (`404`) |
| --- | --- | --- | | --- | --- | --- |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, the Dashboard's pages (`/modules`, `/settings`, `/account`, each with everything below it), and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` | | Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` | | API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | | | Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
@@ -346,9 +348,10 @@ networks:
Then make your proxy do what `Caddyfile.example` does: Then make your proxy do what `Caddyfile.example` does:
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`, - forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
and the Dashboard's pages: `/modules`, `/settings`, and `/account`, each bare and with everything and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
below it (ADR 0057) — without them, refreshing any Dashboard page other than `/` is your proxy's `/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
`404`; anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip - **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events); - stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
@@ -414,7 +417,7 @@ deployment:
| # | Item | Where it is met | | # | Item | Where it is met |
| --- | --- | --- | | --- | --- | --- |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, the Dashboard's pages (`/modules`, `/settings`, `/account` and below), `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. | | 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. | | 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. | | 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. | | 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -30,50 +30,91 @@ page at the new paths, and the proxies had to forward them.
## Decision ## Decision
### The Dashboard owns a closed set of namespaces ### One top-level URL space, shared by platform pages and module pages
```text ```text
/ the Overview / the Overview
/modules/<module-id> a module's page /settings[/…] Settings
/settings[/…] reserved for Settings /account[/…] the account area
/account[/…] reserved for the account area /<module-id> a module's page: /signage, /calendar, /people
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
``` ```
`DASHBOARD_PAGE_PREFIXES` (`surfaces/shell/src/dashboard-routes.ts`, exported as A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
`@ekklesia/shell/dashboard-routes`) lists `/modules`, `/settings`, and `/account`. It is the one Account area are. "Module" is an implementation concept — how the platform packages a product — and
list: the shell's router parses against it, and `apps/api` registers its page routes from it, so the it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
server cannot serve a path the shell does not interpret and the shell cannot link to a path the kebab-case and Core-validated, which is what makes it usable as a path segment with no further
server refuses. None of the three overlaps a namespace something else owns — `/api`, `/display`, or encoding.
`/assets` — and a test holds that.
The namespaces are reserved whole, including `/settings` and `/account`, which have no pages yet. ### Platform-owned top-level names are reserved, and Core enforces it
Reserving them now means that adding a Settings or Account page never requires another server change
or another edit to every installation's proxy configuration, which is an operator's job and easy to
miss.
Module pages live under a prefix rather than at the root (`/signage`) so that no module id can ever Sharing one namespace means a module must never be able to take a name the platform uses.
collide with a platform namespace and no platform namespace added later can shadow an installed `RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
module. Module ids are manifest-validated and Core-owned; reserving words in that grammar would be a at the top level **today**, taken from what is actually registered rather than from guesses:
contract change for a cosmetic gain. The prefix is an internal identifier in ADR 0046's sense, not
branding.
### The server serves one page, unchanged, at every Dashboard path | Name | Owned by |
| --- | --- |
| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
| `assets` | the Dashboard's and display host's scripts |
| `display` | the display host's pages and service worker |
| `settings` | the Dashboard's Settings pages |
| `account` | the Dashboard's account area |
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at each prefix, and at Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
each `prefix/*`, with **the same frozen bytes, the same strong `ETag`, and the same security headers** startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
as before. The path selects a page inside the shell, never a file on the server: nothing from the not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths. A look-alike (`/settings-old`, `/modulesx`), any other
method, and every other path still fall through to the sanitized `404`. The page grants no authority
wherever it is served; it renders the sign-in form until `/api/v1/principal` says otherwise.
An unknown page inside a reserved namespace is answered `200` with the page, and the shell renders its The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
own *Page not found*. That is the price of letting the shell, which owns page meaning, decide what composed API registers is a reserved name or a module page, the display host's and Dashboard
exists; the server knows nothing of modules' or Settings' pages and should not. script's path constants all start with a reserved name, and every Dashboard page the shell
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
is reserved. Reserving a name later is a **breaking change** for any installation with a module of
that id, so the set grows only deliberately, and only for a real route.
Both reference proxies forward exactly these paths (each prefix bare and with `/*`). In the split The rule lives in Core's discovery rather than in the module manifest grammar
topology (ADR 0045) they belong to the Dashboard hostname; the API hostname still serves no Dashboard (`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
page. The development server falls back to the page for any path with no file. standalone host (ADR 0021) has no Settings page to protect. A module built against the current
public packages is unaffected unless it uses one of these five names.
### The server serves one page, unchanged, at every real Dashboard address — and nowhere else
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and
`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as
`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at
`/<module-id>` and everything below it for **each module this process discovered that declares a
Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security
headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from
the request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths.
The module set is the boot snapshot's discovered inventory, fixed at startup like every other
module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a
module an administrator turned off still opens the Dashboard, which says the module is unavailable;
that is more useful than an address that suddenly looks like it never existed. A module that is not
installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`),
a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
appears twice — discovery already guarantees all three, and the routes do not trust it.
Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a
Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the
display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather
than any data, and the alternative — a page at every module-shaped root path — is exactly the
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
and every module route keep their authorization unchanged.
### The proxies forward module-shaped names and let the API decide
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API
answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`,
`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The
development server falls back to the page for any path with no file.
### The shell's router is host-owned, small, and deliberately not a library ### The shell's router is host-owned, small, and deliberately not a library
@@ -94,17 +135,19 @@ a separate decision (see **Deferred**).
- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is - `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
show yet. show yet.
- `/modules/<id>` mounts the module whose navigation entry has that id, through the unchanged generic - `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic
loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's
navigation is the only authority on what exists. An id it does not list — a disabled, blocked, or navigation is the only authority on what is mountable. An id it does not list — a disabled or
removed module, one with no UI, or a typo — shows *This page isn't available* and loads nothing; blocked module whose address the server still serves — shows *This page isn't available* and loads
the page never echoes the address back. This is what makes a disabled module's routes stop behaving nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
as active routes, with no new server state. behaving as active routes, with no new server state.
- Everything else — including anything below `/modules/<id>/` and every `/settings` and `/account` - `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
path for now — is *Page not found*, with a link to the Overview. The shell never guesses. have no content yet, so they show *Page not found* for now.
- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today
and shows *Page not found* in the shell until the module page contract exists. The shell never
guesses, and never shows a module's first page for an address the module never defined.
- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new - One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new
entry), so what is bookmarked next is the one canonical address. Module ids are percent-encoded entry), so what is bookmarked next is the one canonical address.
into exactly one segment.
### Navigation is real links ### Navigation is real links
@@ -132,35 +175,45 @@ verification and reset links still land on `/` with a query (ADR 0038).
## Alternatives ## Alternatives
- **Hash routing (`/#/modules/signage`).** Rejected: it needs no server change, but every address is - **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
then one page to the server, the proxy, and any analytics; fragments are not sent to the server, so a in every address people see, and makes a module a second-class destination beside Settings. The
later server-side decision about a path is impossible; and DESIGN.md asks for real URLs. collision it avoided is handled instead by reserving the platform's names in Core.
- **Module pages at the root (`/signage/…`).** Rejected: module ids would collide with platform - **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
namespaces, and reserving words would be a manifest-grammar change for a shorter address. any analytics; fragments never reach the server, so no server-side decision about a path is
- **A server catch-all that serves the page for any unknown `GET`.** Rejected: it would answer `200` possible; and DESIGN.md asks for real URLs.
for every mistyped API or asset path a browser requests, and it reverses the ADR 0044 posture that - **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
the API serves only paths it means to. would become a Dashboard page, and the server would answer `200` for every mistyped address. It
- **Listing only the pages that exist (`/`, `/modules/*`).** Rejected: every Settings or Account page would remove the installed-module disclosure described above, at the cost of the server no longer
would then need a server change and every installation's proxy edited again. meaning anything by a `200`.
- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an
administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the
Dashboard explaining that the module is unavailable.
- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit.
- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved
name refuses a real module, so a name is reserved when the platform gets a route there and not
before.
- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's
URL space, not a module property, and would need a public-contract release for no extra safety,
since discovery already refuses the module before it can be installed.
- **`react-router-dom` or another routing library.** Rejected for now; see above. - **`react-router-dom` or another routing library.** Rejected for now; see above.
- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page, - **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page,
and an address that silently means "whatever happens to be first" is not a stable address. and an address that silently means "whatever happens to be first" is not a stable address.
- **Showing the module's page for any path below `/modules/<id>/`.** Rejected: a module's own pages
need a contract. Mounting the module's first page for an address it never defined would make a
later contract break bookmarks people had already made.
## Consequences ## Consequences
- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the - A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the
Dashboard's own pages. Proven at three levels: the shell against an in-memory history, `apps/api` Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
serving the page at every reserved namespace and refusing look-alikes, and the deploy smoke test serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
reloading a module's address through the real split-topology proxy in a real browser. else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown
names, a module's address staying served while the module is disabled and disappearing and
returning as it is removed and reinstalled,
and reloading a module's address in a real browser and requiring the same module to mount again.
- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in - **The Dashboard opens on the Overview, not on the first module.** A person who used to land in
Signage now chooses it once from the navigation, and can bookmark its address. Signage now chooses it once from the navigation, and can bookmark `/signage`.
- **Operators with their own proxy must forward the new paths.** An installation using the reference - **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
Caddyfiles gets them by copying the new examples; one with its own proxy must add `/modules`, is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
`/settings`, `/account` and everything below each, or a refresh on those pages will be the proxy's - **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
`404`. The deployment guide says so. or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so.
- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains - `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains
private; this is the same relationship `./dashboard-assets` already has. private; this is the same relationship `./dashboard-assets` already has.
- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the - The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the
@@ -168,11 +221,14 @@ verification and reset links still land on `/` with a query (ADR 0038).
## Deferred ## Deferred
Each of these needs its own decision; this ADR reserves addresses for them and decides none of them: Each of these needs its own decision. This ADR reserves the addresses for them and decides none of
them:
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar, how - **A module's own pages** — how a module declares the pages that appear under it in the sidebar
it learns and changes its current sub-path through the module UI SDK, and what an address below (`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
`/modules/<id>/` means. That is a manifest and SDK contract change and its own ADR. current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The
server already serves the page there and the proxies already forward it, so that contract is a
manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR.
- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and - **Return to origin** — how a module or a Settings page links into another part of the Dashboard and
comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain
a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page
+8 -6
View File
@@ -260,12 +260,14 @@ have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md). [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL
is a real page address: the API serves the Dashboard page at `/` and anywhere under `/modules`, is a real page address: the API serves the Dashboard page at `/`, at `/settings` and `/account`, and
`/settings`, and `/account` (each bare and with everything below it), with the same bytes, validator, at `/<module-id>` for every discovered module that declares a Dashboard UI (enabled or not) — each
and headers as `/`. A deep link opened while signed out therefore shows the sign-in form at that bare and with everything below it — with the same bytes, validator, and headers as `/`. A deep link
address and opens its own page once the person signs in — still with no return value and no opened while signed out therefore shows the sign-in form at that address and opens its own page once
redirect. Any other path, including look-alikes such as `/settings-old`, remains the sanitized the person signs in — still with no return value and no redirect. Any other path — a module that is
`404`. not installed, a display-only module, a look-alike such as `/settings-old` — remains the sanitized
`404`. Module ids `account`, `api`, `assets`, `display`, and `settings` are reserved for the platform
and refused by discovery (`MODULE_ID_RESERVED`).
### The four anonymous screens ### The four anonymous screens
+6 -1
View File
@@ -535,7 +535,12 @@ without executing module code:
} }
``` ```
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability Module, capability, and permission identifiers are lowercase and namespaced. A module with a
Dashboard UI is addressed at `/<module-id>` — `/calendar` for this one
([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same
top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a
module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and
package verification): `account`, `api`, `assets`, `display`, and `settings`. A provided capability
has its own exact semantic version because its public contract can evolve independently from the has its own exact semantic version because its public contract can evolve independently from the
module release. Capability dependencies use semantic-version ranges. Optional means the provider module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible. may be absent; if it is installed, its capability version must be compatible.
+1 -1
View File
@@ -231,7 +231,7 @@ if (hasModule) {
// Dashboard page there, and the shell mounts the same module again (ADR 0057). // Dashboard page there, and the shell mounts the same module again (ADR 0057).
const moduleUrl = await evaluate("location.href"); const moduleUrl = await evaluate("location.href");
check( check(
moduleUrl === `${dashboardOrigin}/modules/${moduleId}`, moduleUrl === `${dashboardOrigin}/${moduleId}`,
"opening the module put its own address in the address bar", "opening the module put its own address in the address bar",
); );
await navigate(moduleUrl); await navigate(moduleUrl);
+29 -4
View File
@@ -88,8 +88,9 @@ async function edgeChecks() {
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)", "the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
); );
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). // Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
for (const deepLink of ["/modules/example", "/settings/displays/add", "/account"]) { // is installed yet at this point; a module's own address is reloaded in a real browser later.
for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) {
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`); const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
check( check(
deepPage.status === 200 && deepPage.status === 200 &&
@@ -98,10 +99,15 @@ async function edgeChecks() {
`GET ${deepLink} through the proxy is the same Dashboard page`, `GET ${deepLink} through the proxy is the same Dashboard page`,
); );
} }
// A top-level name shaped like a module id reaches the API, which knows the installed modules and
// answers anything that is not a Dashboard page with its sanitized 404 — never the page.
for (const unknown of ["/definitely-not-a-real-module", "/settings-old", "/modules/example"]) {
const response = await fetch(`${dashboardOrigin}${unknown}`);
check( check(
await refusedByProxy(`${dashboardOrigin}/settings-old`), response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
"a look-alike of a Dashboard namespace is refused by the proxy", `GET ${unknown} through the proxy is the API's sanitized 404, not a Dashboard page`,
); );
}
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`); const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer(); await script.arrayBuffer();
@@ -510,6 +516,25 @@ async function moduleChecks() {
check(installed, `the module inventory lists the \`${moduleId}\` module`); check(installed, `the module inventory lists the \`${moduleId}\` module`);
} }
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
// not. A disabled module is not running, but its address still opens the Dashboard.
const pageExpectation =
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
const modulePageBody = await modulePage.text();
if (pageExpectation === "absent") {
check(
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
);
} else {
check(
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
);
}
if (moduleId === "example") { if (moduleId === "example") {
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers }); const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") { if (expectation === "absent") {
+6 -2
View File
@@ -543,9 +543,13 @@ recreate_api() {
fi fi
} }
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return. # module_check <expectation> [page]: "absent", or the exact JSON array of values `example` must
# return. `page` is whether its Dashboard address is served (ADR 0057): by default, exactly when the
# module is running; "served" for a module that is installed but disabled, whose address still opens
# the Dashboard so it can say the module is unavailable.
module_check() { module_check() {
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \ client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
-e SMOKE_MODULE_PAGE="${2:-}" \
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \ -e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
} }
@@ -729,7 +733,7 @@ if [[ "$topology" == split ]]; then
step "Disabling and re-enabling the module through the supported lifecycle command" step "Disabling and re-enabling the module through the supported lifecycle command"
restart_api "${compose[@]}" run --rm --no-deps migrate node \ restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --disable --module "$module_id" core/dist/cli/set-module-enabled.js --disable --module "$module_id"
module_check absent || fail "the disabled module was still reported as running." module_check absent served || fail "the disabled module was still reported as running."
module_storage "test -d /modules/${module_id}" || module_storage "test -d /modules/${module_id}" ||
fail "disabling the module removed its installed package." fail "disabling the module removed its installed package."
restart_api "${compose[@]}" run --rm --no-deps migrate node \ restart_api "${compose[@]}" run --rm --no-deps migrate node \
+2 -2
View File
@@ -90,8 +90,8 @@ if (serve) {
const context = await esbuild.context(buildOptions); const context = await esbuild.context(buildOptions);
await context.watch(); await context.watch();
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as // Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
// `/modules/example` reaches the shell's router here as it does from `apps/api` (ADR 0057). // `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
// Development only: the API serves the page at exactly the Dashboard's reserved paths instead. // Development only: the API serves the page only at its platform pages and installed modules'.
const { host, port } = await context.serve({ const { host, port } = await context.serve({
fallback: path.join(outputDirectory, "index.html"), fallback: path.join(outputDirectory, "index.html"),
servedir: outputDirectory, servedir: outputDirectory,
+1 -1
View File
@@ -342,7 +342,7 @@ export function HostApp(props: HostAppProps): ReactElement {
setState(resolved); 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. // history entry, so what is bookmarked or shared next is the one canonical address.
useEffect(() => { useEffect(() => {
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) { if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
+37 -36
View File
@@ -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 * 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, * address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
* so a refreshed or bookmarked deep link reaches the shell instead of a `404`. One list, imported by * refreshed or bookmarked deep link reaches the shell instead of a `404`.
* 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. * 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 * The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
* yet, so that adding a Settings or Account page never needs another server or proxy change. * 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.
* 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([ export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
"/modules",
"/settings",
"/account",
] as const);
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */ /** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute = 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. */ /** 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" }>; 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 const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
* 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 { function decodeSegment(segment: string): string | undefined {
try { try {
@@ -53,12 +50,15 @@ function decodeSegment(segment: string): string | undefined {
/** /**
* Reads a path into a route. * Reads a path into a route.
* *
* `/modules/<id>` names a module's page. The module id is only decoded here, never validated against * A single top-level segment that is not a platform page names a module's page. The id is only
* a pattern: the shell compares it with the navigation the server resolved, and an id that matches * decoded here, never validated against a pattern: the shell compares it with the navigation the
* no entry is an unavailable module, not a malformed URL. Everything below `/modules/<id>/` belongs * server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
* 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 * Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
* a person typing a URL expects; the shell then replaces it with the canonical spelling. * 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 { export function parseDashboardPath(pathname: string): DashboardRoute {
if (pathname === "/") { if (pathname === "/") {
@@ -69,11 +69,12 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
return { kind: "not-found" }; return { kind: "not-found" };
} }
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1); const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
if (meaningful.length === 2 && meaningful[0] === "modules") { const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
const moduleId = decodeSegment(meaningful[1] ?? ""); if (first === undefined || platformPages.has(first)) {
if (moduleId !== undefined) { return { kind: "not-found" };
return { kind: "module", moduleId };
} }
if (meaningful.length === 1) {
return { kind: "module", moduleId: first };
} }
return { kind: "not-found" }; return { kind: "not-found" };
} }
@@ -84,5 +85,5 @@ export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") { if (route.kind === "overview") {
return "/"; return "/";
} }
return `/modules/${encodeURIComponent(route.moduleId)}`; return `/${encodeURIComponent(route.moduleId)}`;
} }
+26 -76
View File
@@ -4,60 +4,17 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DASHBOARD_PAGE_PREFIXES, DASHBOARD_PLATFORM_PAGES,
dashboardPath, dashboardPath,
isDashboardPagePath,
parseDashboardPath, parseDashboardPath,
} from "../src/dashboard-routes.js"; } from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("the Dashboard URL space", () => { describe("the Dashboard's own top-level pages", () => {
it("reserves exactly the platform's page namespaces, frozen", () => { it("are exactly Settings and Account, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PAGE_PREFIXES)).toBe(true); expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/settings", "/account"]); expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
it("never reserves a namespace the API, display host, or host assets already own", () => {
for (const prefix of DASHBOARD_PAGE_PREFIXES) {
for (const owned of ["/api", "/display", "/assets"]) {
expect(prefix === owned || prefix.startsWith(`${owned}/`), prefix).toBe(false);
expect(owned.startsWith(`${prefix}/`), prefix).toBe(false);
}
}
});
it("recognizes the root and every path inside a reserved namespace as a Dashboard page", () => {
for (const pathname of [
"/",
"/modules",
"/modules/",
"/modules/signage",
"/modules/signage/timelines/abc",
"/settings",
"/settings/displays/add",
"/account",
"/account/appearance",
]) {
expect(isDashboardPagePath(pathname), pathname).toBe(true);
}
});
it("does not claim anything outside them, including look-alike prefixes", () => {
for (const pathname of [
"",
"/api/v1/principal",
"/display/signage",
"/assets/ekklesia-dashboard/app.js",
"/modulesx",
"/settings-old",
"/accounts",
"/index.html",
"/login",
"modules/signage",
]) {
expect(isDashboardPagePath(pathname), pathname).toBe(false);
}
}); });
}); });
@@ -66,40 +23,34 @@ describe("parseDashboardPath", () => {
expect(parseDashboardPath("/")).toEqual({ kind: "overview" }); expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
}); });
it("names a module page by its id, tolerating one trailing slash", () => { it("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/modules/signage")).toEqual({ expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
kind: "module", expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
moduleId: "signage", expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
});
expect(parseDashboardPath("/modules/signage/")).toEqual({
kind: "module",
moduleId: "signage",
});
}); });
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => { it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/modules/sign%61ge")).toEqual({ expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
kind: "module",
moduleId: "signage",
}); });
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
}); });
it("treats every other shape as not found rather than guessing", () => { it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [ for (const pathname of [
"", "",
"/modules", "//",
"/modules/", "//signage",
"/modules//", "/signage//",
"//modules/signage",
"/modules/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057). // A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/modules/signage/timelines", "/signage/timelines",
"/modules/%E0%A4%A", "/signage/timelines/abc",
"/settings", "/%E0%A4%A",
"/settings/displays", "/settings/displays/add",
"/account", "signage",
"/overview",
"/index.html",
]) { ]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" }); expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
} }
@@ -107,13 +58,13 @@ describe("parseDashboardPath", () => {
}); });
describe("dashboardPath", () => { describe("dashboardPath", () => {
it("spells each route canonically", () => { it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/"); expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/modules/signage"); expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
}); });
it("encodes a module id so it can never escape its own segment", () => { it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/modules/a%2F..%2Fb"); expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({ expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module", kind: "module",
moduleId: "a/../b", moduleId: "a/../b",
@@ -126,7 +77,6 @@ describe("dashboardPath", () => {
{ kind: "module", moduleId: "example" } as const, { kind: "module", moduleId: "example" } as const,
]) { ]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route); expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
expect(isDashboardPagePath(dashboardPath(route))).toBe(true);
} }
}); });
}); });
+1 -1
View File
@@ -398,7 +398,7 @@ describe("display host — full-viewport boundary", () => {
root.render( root.render(
<HostApp <HostApp
config={config} config={config}
history={createMemoryHostHistory("/modules/announcements")} history={createMemoryHostHistory("/announcements")}
importModule={importer} importModule={importer}
/>, />,
), ),
+6 -6
View File
@@ -287,7 +287,7 @@ describe("Ekklesia shell — browser authentication", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as // These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing. // its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => { beforeEach(() => {
window.history.replaceState(null, "", "/modules/example"); window.history.replaceState(null, "", "/example");
}); });
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => { it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined; let releasePrincipal: (() => void) | undefined;
@@ -563,7 +563,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as // These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing. // its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => { beforeEach(() => {
window.history.replaceState(null, "", "/modules/example"); window.history.replaceState(null, "", "/example");
}); });
it("unmounts module UI and closes realtime before completing server-side logout", async () => { it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined; let releaseSignOut: (() => void) | undefined;
@@ -938,7 +938,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
await render( await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, { apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async () => artifact("livestream", "2.0.0"), async () => artifact("livestream", "2.0.0"),
"/modules/livestream", "/livestream",
); );
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page"); expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
@@ -954,7 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
requested.push(url); requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0"); return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
}, },
"/modules/video", "/video",
); );
await act(async () => { await act(async () => {
@@ -1015,7 +1015,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as // These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing. // its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => { beforeEach(() => {
window.history.replaceState(null, "", "/modules/example"); window.history.replaceState(null, "", "/example");
}); });
it("keeps fixed shell tracks while module content changes size", async () => { it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0")); await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
@@ -1188,7 +1188,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as // These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing. // its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => { beforeEach(() => {
window.history.replaceState(null, "", "/modules/example"); window.history.replaceState(null, "", "/example");
}); });
it("renders its own error when the artifact cannot be imported", async () => { it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
+21 -21
View File
@@ -14,21 +14,21 @@ describe("the browser host history", () => {
const history = createBrowserHostHistory(window); const history = createBrowserHostHistory(window);
const lengthBefore = window.history.length; const lengthBefore = window.history.length;
history.push("/modules/example"); history.push("/example");
expect(history.pathname()).toBe("/modules/example"); expect(history.pathname()).toBe("/example");
expect(window.location.pathname).toBe("/modules/example"); expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(lengthBefore + 1); expect(window.history.length).toBe(lengthBefore + 1);
}); });
it("adds no entry when the page asked for is already open", () => { it("adds no entry when the page asked for is already open", () => {
const history = createBrowserHostHistory(window); const history = createBrowserHostHistory(window);
history.push("/modules/example"); history.push("/example");
const length = window.history.length; const length = window.history.length;
const listener = vi.fn(); const listener = vi.fn();
history.subscribe(listener); history.subscribe(listener);
history.push("/modules/example"); history.push("/example");
expect(window.history.length).toBe(length); expect(window.history.length).toBe(length);
expect(listener).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled();
@@ -36,12 +36,12 @@ describe("the browser host history", () => {
it("corrects the current entry in place with replace", () => { it("corrects the current entry in place with replace", () => {
const history = createBrowserHostHistory(window); const history = createBrowserHostHistory(window);
history.push("/modules/example/"); history.push("/example/");
const length = window.history.length; const length = window.history.length;
history.replace("/modules/example"); history.replace("/example");
expect(window.location.pathname).toBe("/modules/example"); expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(length); expect(window.history.length).toBe(length);
}); });
@@ -50,14 +50,14 @@ describe("the browser host history", () => {
const listener = vi.fn(); const listener = vi.fn();
const unsubscribe = history.subscribe(listener); const unsubscribe = history.subscribe(listener);
history.push("/modules/a"); history.push("/a");
history.replace("/modules/b"); history.replace("/b");
window.dispatchEvent(new PopStateEvent("popstate")); window.dispatchEvent(new PopStateEvent("popstate"));
expect(listener).toHaveBeenCalledTimes(3); expect(listener).toHaveBeenCalledTimes(3);
unsubscribe(); unsubscribe();
window.dispatchEvent(new PopStateEvent("popstate")); window.dispatchEvent(new PopStateEvent("popstate"));
history.push("/modules/c"); history.push("/c");
expect(listener).toHaveBeenCalledTimes(3); expect(listener).toHaveBeenCalledTimes(3);
}); });
@@ -92,28 +92,28 @@ describe("the browser host history", () => {
}; };
const history = createBrowserHostHistory(view); const history = createBrowserHostHistory(view);
history.push("/modules/a"); history.push("/a");
history.replace("/modules/b"); history.replace("/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/modules/a"); expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/modules/b"); expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/b");
}); });
}); });
describe("the in-memory host history", () => { describe("the in-memory host history", () => {
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => { it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
const history = createMemoryHostHistory("/"); const history = createMemoryHostHistory("/");
history.push("/modules/a"); history.push("/a");
history.push("/modules/b"); history.push("/b");
history.go(-1); history.go(-1);
expect(history.pathname()).toBe("/modules/a"); expect(history.pathname()).toBe("/a");
history.push("/modules/c"); history.push("/c");
expect(history.entries).toEqual(["/", "/modules/a", "/modules/c"]); expect(history.entries).toEqual(["/", "/a", "/c"]);
history.go(-10); history.go(-10);
expect(history.pathname()).toBe("/"); expect(history.pathname()).toBe("/");
history.go(10); history.go(10);
expect(history.pathname()).toBe("/modules/c"); expect(history.pathname()).toBe("/c");
}); });
}); });
+28 -17
View File
@@ -166,7 +166,7 @@ describe("the Dashboard opens on the page its URL names", () => {
}); });
it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => { it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => {
const { imported, titles } = await render("/modules/calendar"); const { imported, titles } = await render("/calendar");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted"); expect(isolatedText(query("module-container"))).toBe("calendar mounted");
@@ -177,14 +177,14 @@ describe("the Dashboard opens on the page its URL names", () => {
}); });
it("opens a module from inside a group exactly as one outside it", async () => { it("opens a module from inside a group exactly as one outside it", async () => {
const { imported } = await render("/modules/signage"); const { imported } = await render("/signage");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]);
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page"); expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
}); });
it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => { it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => {
const { imported, titles } = await render("/modules/disabled-module"); const { imported, titles } = await render("/disabled-module");
expect(query("host-module-unavailable")).not.toBeNull(); expect(query("host-module-unavailable")).not.toBeNull();
expect(query("module-container")).toBeNull(); expect(query("module-container")).toBeNull();
@@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => {
}); });
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => { it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays", "/modules/calendar/events", "/account"]) { for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
}); });
@@ -211,16 +211,27 @@ describe("the Dashboard opens on the page its URL names", () => {
} }
}); });
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => { it("addresses module pages at the top level, beside the platform's own pages, and never confuses the two", async () => {
const { history, imported } = await render("/modules/calendar/"); // Core refuses a module whose id is a platform page, but even a navigation that somehow listed
// one could not turn `/settings` into a module page.
const { imported } = await render("/settings");
expect(history.entries).toEqual(["/modules/calendar"]); expect(query("host-not-found")).not.toBeNull();
expect(imported).toEqual([]);
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
expect(container.innerHTML).not.toContain("/modules/");
});
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/calendar/");
expect(history.entries).toEqual(["/calendar"]);
expect(imported).toHaveLength(1); expect(imported).toHaveLength(1);
}); });
it("opens the deep link it was given once the person signs in, with no redirect", async () => { it("opens the deep link it was given once the person signs in, with no redirect", async () => {
const history = createMemoryHostHistory("/modules/calendar"); const history = createMemoryHostHistory("/calendar");
const { imported } = await render("/modules/calendar", { authenticated: false, history }); const { imported } = await render("/calendar", { authenticated: false, history });
expect(query("host-login")).not.toBeNull(); expect(query("host-login")).not.toBeNull();
const email = query("login-email") as HTMLInputElement; const email = query("login-email") as HTMLInputElement;
@@ -240,7 +251,7 @@ describe("the Dashboard opens on the page its URL names", () => {
expect(isolatedText(query("module-container"))).toBe("calendar mounted"); expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(history.entries).toEqual(["/modules/calendar"]); expect(history.entries).toEqual(["/calendar"]);
}); });
}); });
@@ -250,8 +261,8 @@ describe("moving between pages", () => {
for (const [testId, href] of [ for (const [testId, href] of [
["select-overview", "/"], ["select-overview", "/"],
["select-signage", "/modules/signage"], ["select-signage", "/signage"],
["select-calendar", "/modules/calendar"], ["select-calendar", "/calendar"],
] as const) { ] as const) {
const link = query(testId); const link = query(testId);
expect(link?.tagName, testId).toBe("A"); expect(link?.tagName, testId).toBe("A");
@@ -266,7 +277,7 @@ describe("moving between pages", () => {
const event = await click(query("select-calendar")); const event = await click(query("select-calendar"));
expect(event.defaultPrevented).toBe(true); expect(event.defaultPrevented).toBe(true);
expect(history.entries).toEqual(["/", "/modules/calendar"]); expect(history.entries).toEqual(["/", "/calendar"]);
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted"); expect(isolatedText(query("module-container"))).toBe("calendar mounted");
}); });
@@ -288,11 +299,11 @@ describe("moving between pages", () => {
}); });
it("adds no entry when the open page's own link is clicked again", async () => { it("adds no entry when the open page's own link is clicked again", async () => {
const { history } = await render("/modules/calendar"); const { history } = await render("/calendar");
await click(query("select-calendar")); await click(query("select-calendar"));
expect(history.entries).toEqual(["/modules/calendar"]); expect(history.entries).toEqual(["/calendar"]);
}); });
it("walks Back and Forward through exactly the pages that were open", async () => { it("walks Back and Forward through exactly the pages that were open", async () => {
@@ -319,11 +330,11 @@ describe("moving between pages", () => {
}); });
it("returns to the Overview from an unavailable page through its own link", async () => { it("returns to the Overview from an unavailable page through its own link", async () => {
const { history } = await render("/modules/missing"); const { history } = await render("/missing");
await click(query("host-go-to-overview")); await click(query("host-go-to-overview"));
expect(history.entries).toEqual(["/modules/missing", "/"]); expect(history.entries).toEqual(["/missing", "/"]);
expect(query("host-overview")).not.toBeNull(); expect(query("host-overview")).not.toBeNull();
}); });
+50 -7
View File
@@ -188,12 +188,13 @@ describe("reference deployment compose files", () => {
.filter((line) => !line.trim().startsWith("#")) .filter((line) => !line.trim().startsWith("#"))
.join("\n"); .join("\n");
// The Dashboard's reserved namespaces (ADR 0057, `DASHBOARD_PAGE_PREFIXES` in expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
// `surfaces/shell/src/dashboard-routes.ts`), each both bare and with everything below it, so a // The Dashboard's other pages (ADR 0057): every top-level name shaped like a module id, and
// refreshed deep link reaches the API instead of the proxy's 404. // nothing else, so the API — which knows the installed modules — decides page or 404.
expect(directives).toContain( expect(directives).toContain(
"@ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/*", `@dashboardPage path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`,
); );
expect(directives).toMatch(/handle @dashboardPage \{\s*reverse_proxy api:3000 \{/u);
expect(directives).toContain("header_up X-Forwarded-For {client_ip}"); expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP"); expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32"); expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
@@ -205,6 +206,46 @@ describe("reference deployment compose files", () => {
}); });
}); });
/** The proxies' Dashboard-page matcher: a module id's grammar (`@ekklesia/contracts`) as the first
* segment, then anything. Checked against real paths below so the regular expression itself is
* tested, not just its spelling. */
const DASHBOARD_PAGE_PATTERN = "^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$";
describe("the proxies' Dashboard-page matcher", () => {
const matcher = new RegExp(DASHBOARD_PAGE_PATTERN, "u");
it("forwards every Dashboard page and module-shaped name", () => {
for (const path of [
"/settings",
"/settings/displays/add",
"/account",
"/signage",
"/signage/",
"/signage/timelines",
"/sunday-production",
"/definitely-not-a-real-module",
]) {
expect(matcher.test(path), path).toBe(true);
}
});
it("forwards nothing that could not be a module id", () => {
for (const path of [
"/",
"/favicon.ico",
"/robots.txt",
"/.well-known/x",
"/Signage",
"/-x",
"/1x",
"//signage",
"/a--b",
]) {
expect(matcher.test(path), path).toBe(false);
}
});
});
describe("split-origin reference proxy", () => { describe("split-origin reference proxy", () => {
it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => { it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => {
const caddyfile = await text("Caddyfile.split.example"); const caddyfile = await text("Caddyfile.split.example");
@@ -213,9 +254,11 @@ describe("split-origin reference proxy", () => {
.filter((line) => !line.trim().startsWith("#")) .filter((line) => !line.trim().startsWith("#"))
.join("\n"); .join("\n");
expect(directives).toContain( expect(directives).toContain("@dashboard path / /assets/ekklesia-dashboard/*");
"@dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/*", expect(directives).toContain(`path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`);
); // The API's own namespaces stay refused on the Dashboard hostname even though they are shaped
// like a module id.
expect(directives).toContain("not path /api /api/* /display /display/* /assets /assets/*");
// The API hostname still serves no Dashboard page at all. // The API hostname still serves no Dashboard page at all.
expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*"); expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*");
}); });