feat(shell): make module pages first-class top-level routes
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`), beside `/settings` and `/account`, so the implementation concept "module" never appears in a user-facing address. ADR 0057 is rewritten to match. - Core reserves the top-level names the platform actually serves today (`account`, `api`, `assets`, `display`, `settings`), and discovery refuses a module that declares one with `MODULE_ID_RESERVED`. That covers startup, `modules:validate`, and package verification. Tests in `apps/api` hold the set to every top-level route the API registers and every page the shell interprets. - The API serves the Dashboard page at `/<module-id>` and below only for modules discovered with a Dashboard UI, enabled or not: a disabled module's bookmark still opens the Dashboard, which says it is unavailable. An arbitrary root path such as `/definitely-not-a-real-module` stays the sanitized 404. - Both reference proxies forward every top-level path shaped like a module id and let the API decide; the split Dashboard hostname still refuses `/api`, `/display`, and `/assets`. Installing a module needs no proxy change. - `/<module-id>/<page>` is already served and forwarded, so a later module sub-page contract needs no routing or deployment change; the shell shows "Page not found" there until it exists. - The deploy smoke checks a module's address before install, while installed, while disabled, after removal, and after reinstall, plus unknown names and the old `/modules/…` form through the real proxy. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9afe9376c4
commit
ba60f70835
@@ -908,12 +908,14 @@ module — Signage included — still do not exist, and nothing in Phase 8 claim
|
||||
|
||||
Stage 1 begins with **real Dashboard URLs**
|
||||
([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,
|
||||
`/modules/<module-id>`, so a refresh, a bookmark, and a shared link land on the page that was open
|
||||
and Back and Forward walk the Dashboard's own pages. The API serves the same Dashboard page at `/` and
|
||||
throughout the reserved `/modules`, `/settings`, and `/account` namespaces, and both reference proxies
|
||||
forward them. A module that is not in the server's navigation — disabled, removed, or with no UI —
|
||||
shows a host-owned *This page isn't available* rather than loading anything. A module's own
|
||||
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
|
||||
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
|
||||
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
|
||||
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
|
||||
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
|
||||
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
|
||||
link came from are later Stage 1 work.
|
||||
|
||||
|
||||
+12
-4
@@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
// own entirely. See `./health-routes.js`.
|
||||
app.route("/", createHealthRoutes(options.health));
|
||||
|
||||
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||
// `./dashboard-host-routes.js`.
|
||||
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
|
||||
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
|
||||
// `/<module-id>`. They grant no authority and resolve no principal; the page asks
|
||||
// `/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(
|
||||
"/",
|
||||
|
||||
@@ -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 { etagMatches } from "./conditional-request.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
|
||||
* serves: the page answers at `/` and inside the Dashboard's reserved namespaces, which the shell
|
||||
* exports so this server and the shell's router read one list, and the script lives beside, never
|
||||
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||
* `/api/v1/modules/<module-id>`, and every emailed verification or reset link still lands on `/` with
|
||||
* a query (ADR 0038).
|
||||
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
|
||||
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
|
||||
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
|
||||
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
|
||||
* `/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_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
|
||||
* default same-origin shape.
|
||||
*
|
||||
* 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
|
||||
* Dashboard namespace — `/settings/displays/add` as much as `/settings` — with the **same** bytes and
|
||||
* the same validator, because the path selects a page inside the shell rather than a file here; that
|
||||
* is what lets a refreshed or bookmarked deep link stay where it was. Any other path — `/index.html`,
|
||||
* `/app.js`, the `.map`, a look-alike such as `/settings-old`, a traversal spelling — has no route
|
||||
* and falls 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.
|
||||
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
|
||||
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
|
||||
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
|
||||
* server change later) — always with the **same** bytes and the same validator, because the path
|
||||
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
|
||||
* bookmarked deep link stay where it was.
|
||||
*
|
||||
* 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 modulePages = validatedModulePages(options.modulePages);
|
||||
const routes = new Hono();
|
||||
|
||||
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")));
|
||||
for (const prefix of DASHBOARD_PAGE_PREFIXES) {
|
||||
routes.get(prefix, (c) => page(c.req.header("if-none-match")));
|
||||
routes.get(`${prefix}/*`, (c) => page(c.req.header("if-none-match")));
|
||||
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
|
||||
routes.get(`/${name}`, (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) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleRouteHandler,
|
||||
parseModuleManifest,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -13,9 +14,13 @@ import {
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
isReservedModuleId,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleConfiguration,
|
||||
RESERVED_MODULE_IDS,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -33,12 +38,23 @@ import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
DashboardHostConfigurationError,
|
||||
createDashboardHostRoutes,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { DASHBOARD_PAGE_PREFIXES } from "@ekklesia/shell/dashboard-routes";
|
||||
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
||||
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 { 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 { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
@@ -79,9 +95,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
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(
|
||||
options: {
|
||||
readonly assets?: DashboardHostAssets;
|
||||
readonly configuration?: ModuleLifecycleConfiguration;
|
||||
readonly discovered?: readonly DiscoveredModule[];
|
||||
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||
} = {},
|
||||
) {
|
||||
@@ -101,7 +148,10 @@ function buildApp(
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: fakeModuleServices(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
@@ -195,24 +245,37 @@ describe("GET / (the Dashboard page)", () => {
|
||||
});
|
||||
|
||||
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 = [
|
||||
"/modules",
|
||||
"/modules/",
|
||||
"/modules/signage",
|
||||
"/modules/signage/timelines/a1b2",
|
||||
"/modules/not-installed",
|
||||
"/settings",
|
||||
"/settings/",
|
||||
"/settings/displays/add",
|
||||
"/account",
|
||||
"/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", () => {
|
||||
expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/settings", "/account"]);
|
||||
it("serves the shell's own platform pages, from the shell's own list", () => {
|
||||
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 () => {
|
||||
const { app } = buildApp();
|
||||
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
|
||||
const { app } = buildApp({ configuration, discovered: installed() });
|
||||
|
||||
for (const path of deepLinks) {
|
||||
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 () => {
|
||||
const { app } = buildApp();
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
const response = await app.request("/settings/displays", {
|
||||
headers: { "If-None-Match": PAGE_ETAG },
|
||||
});
|
||||
for (const path of ["/settings/displays", "/signage"]) {
|
||||
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);
|
||||
expect(await response.text()).toBe("");
|
||||
expectPageSecurityHeaders(response);
|
||||
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
|
||||
const { app } = buildApp({ configuration, discovered: installed() });
|
||||
|
||||
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 () => {
|
||||
const { app } = buildApp();
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
for (const path of [
|
||||
"/modules/app.js",
|
||||
"/signage/app.js",
|
||||
"/settings/assets/ekklesia-dashboard/app.js",
|
||||
"/account/index.html",
|
||||
]) {
|
||||
const response = await app.request(path);
|
||||
expect(await response.text(), path).toBe(PAGE_HTML);
|
||||
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
|
||||
}
|
||||
});
|
||||
|
||||
it("claims no look-alike prefix and no other method", async () => {
|
||||
const { app } = buildApp();
|
||||
it("answers no other method at a Dashboard page", async () => {
|
||||
const { app } = buildApp({ discovered: installed() });
|
||||
|
||||
for (const path of ["/modulesx", "/settings-old", "/accounts", "/Modules/signage"]) {
|
||||
await expectSanitizedNotFound(await app.request(path), path);
|
||||
for (const path of ["/settings/displays", "/signage"]) {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||
}
|
||||
}
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
await expectSanitizedNotFound(
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeParseModuleManifest,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { isReservedModuleId } from "./reserved-ids.js";
|
||||
|
||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||
|
||||
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
|
||||
| "MODULE_ENTRYPOINT_INVALID"
|
||||
| "MODULE_ENTRYPOINT_MISSING"
|
||||
| "MODULE_ID_DUPLICATE"
|
||||
| "MODULE_ID_RESERVED"
|
||||
| "MODULE_MANIFEST_INVALID"
|
||||
| "MODULE_MANIFEST_MISSING"
|
||||
| "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);
|
||||
if (!artifacts) {
|
||||
return undefined;
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from "./module-package-host.js";
|
||||
export * from "./module-package-verification.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./realtime.js";
|
||||
export * from "./reserved-ids.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
export * from "./ui-publication.js";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
isReservedModuleId,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryIssueCode,
|
||||
RESERVED_MODULE_IDS,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
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 () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "future", {
|
||||
|
||||
@@ -41,11 +41,16 @@
|
||||
}
|
||||
|
||||
{$EKKLESIA_HOSTNAME} {
|
||||
# Everything the API serves: the Dashboard at / and at its own pages (/modules, /settings, and
|
||||
# /account, each with everything below it, so a refreshed deep link stays put), the API, display
|
||||
# pages and their service worker, and the Dashboard and display host scripts. Any other path is
|
||||
# refused here.
|
||||
@ekklesia path / /api/* /display/* /assets/* /modules /modules/* /settings /settings/* /account /account/*
|
||||
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
||||
# worker, and the Dashboard and display host scripts.
|
||||
@ekklesia path / /api/* /display/* /assets/*
|
||||
|
||||
# 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.
|
||||
@realtime path /api/v1/module-realtime/*
|
||||
@@ -65,6 +70,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
handle @dashboardPage {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
|
||||
@@ -45,13 +45,21 @@
|
||||
header_up -True-Client-IP
|
||||
}
|
||||
|
||||
# The Dashboard: its page — at `/` and at every Dashboard page (/modules, /settings, and /account,
|
||||
# each with everything below it), so a refreshed deep link stays put — and its one script, nothing
|
||||
# else. Emailed verification and reset links land on `/` with a query. `/api/*` is refused here on
|
||||
# purpose, so the session cookie has exactly one home, the API hostname, and the Dashboard can never
|
||||
# fall back to a same-origin API.
|
||||
# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land
|
||||
# on `/` with a query. `/api/*` is refused here on 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} {
|
||||
@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 {
|
||||
reverse_proxy api:3000 {
|
||||
@@ -59,6 +67,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
handle @dashboardPage {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
|
||||
+12
-9
@@ -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`.
|
||||
|
||||
**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`,
|
||||
`/settings`, `/account` and everything below each; without them the Dashboard still opens at `/`,
|
||||
but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the proxy after
|
||||
editing it.
|
||||
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
|
||||
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
|
||||
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
|
||||
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
|
||||
|
||||
@@ -269,7 +271,7 @@ The same image serves both; only configuration differs.
|
||||
|
||||
| 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 `/` |
|
||||
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||
|
||||
@@ -346,9 +348,10 @@ networks:
|
||||
Then make your proxy do what `Caddyfile.example` does:
|
||||
|
||||
- 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
|
||||
below it (ADR 0057) — without them, refreshing any Dashboard page other than `/` is your proxy's
|
||||
`404`;
|
||||
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
|
||||
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
|
||||
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
|
||||
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
||||
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
||||
@@ -414,7 +417,7 @@ deployment:
|
||||
|
||||
| # | 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. |
|
||||
| 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`. |
|
||||
|
||||
@@ -30,50 +30,91 @@ page at the new paths, and the proxies had to forward them.
|
||||
|
||||
## Decision
|
||||
|
||||
### The Dashboard owns a closed set of namespaces
|
||||
### One top-level URL space, shared by platform pages and module pages
|
||||
|
||||
```text
|
||||
/ the Overview
|
||||
/modules/<module-id> a module's page
|
||||
/settings[/…] reserved for Settings
|
||||
/account[/…] reserved for the account area
|
||||
/ the Overview
|
||||
/settings[/…] Settings
|
||||
/account[/…] 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
|
||||
`@ekklesia/shell/dashboard-routes`) lists `/modules`, `/settings`, and `/account`. It is the one
|
||||
list: the shell's router parses against it, and `apps/api` registers its page routes from it, so the
|
||||
server cannot serve a path the shell does not interpret and the shell cannot link to a path the
|
||||
server refuses. None of the three overlaps a namespace something else owns — `/api`, `/display`, or
|
||||
`/assets` — and a test holds that.
|
||||
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
|
||||
Account area are. "Module" is an implementation concept — how the platform packages a product — and
|
||||
it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
|
||||
kebab-case and Core-validated, which is what makes it usable as a path segment with no further
|
||||
encoding.
|
||||
|
||||
The namespaces are reserved whole, including `/settings` and `/account`, which have no pages yet.
|
||||
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.
|
||||
### Platform-owned top-level names are reserved, and Core enforces it
|
||||
|
||||
Module pages live under a prefix rather than at the root (`/signage`) so that no module id can ever
|
||||
collide with a platform namespace and no platform namespace added later can shadow an installed
|
||||
module. Module ids are manifest-validated and Core-owned; reserving words in that grammar would be a
|
||||
contract change for a cosmetic gain. The prefix is an internal identifier in ADR 0046's sense, not
|
||||
branding.
|
||||
Sharing one namespace means a module must never be able to take a name the platform uses.
|
||||
`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
|
||||
at the top level **today**, taken from what is actually registered rather than from guesses:
|
||||
|
||||
### 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
|
||||
each `prefix/*`, with **the same frozen bytes, the same strong `ETag`, and the same security headers**
|
||||
as before. 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. 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.
|
||||
Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
|
||||
startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
|
||||
not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
|
||||
|
||||
An unknown page inside a reserved namespace is answered `200` with the page, and the shell renders its
|
||||
own *Page not found*. That is the price of letting the shell, which owns page meaning, decide what
|
||||
exists; the server knows nothing of modules' or Settings' pages and should not.
|
||||
The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
|
||||
composed API registers is a reserved name or a module page, the display host's and Dashboard
|
||||
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
|
||||
topology (ADR 0045) they belong to the Dashboard hostname; the API hostname still serves no Dashboard
|
||||
page. The development server falls back to the page for any path with no file.
|
||||
The rule lives in Core's discovery rather than in the module manifest grammar
|
||||
(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
|
||||
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
|
||||
|
||||
@@ -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
|
||||
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
|
||||
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
|
||||
navigation is the only authority on what exists. An id it does not list — a disabled, blocked, or
|
||||
removed module, one with no UI, or a typo — shows *This page isn't available* and loads nothing;
|
||||
the page never echoes the address back. This is what makes a disabled module's routes stop behaving
|
||||
as active routes, with no new server state.
|
||||
- Everything else — including anything below `/modules/<id>/` and every `/settings` and `/account`
|
||||
path for now — is *Page not found*, with a link to the Overview. The shell never guesses.
|
||||
navigation is the only authority on what is mountable. An id it does not list — a disabled or
|
||||
blocked module whose address the server still serves — shows *This page isn't available* and loads
|
||||
nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
|
||||
behaving as active routes, with no new server state.
|
||||
- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
|
||||
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
|
||||
entry), so what is bookmarked next is the one canonical address. Module ids are percent-encoded
|
||||
into exactly one segment.
|
||||
entry), so what is bookmarked next is the one canonical address.
|
||||
|
||||
### Navigation is real links
|
||||
|
||||
@@ -132,35 +175,45 @@ verification and reset links still land on `/` with a query (ADR 0038).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Hash routing (`/#/modules/signage`).** Rejected: it needs no server change, but every address is
|
||||
then one page to the server, the proxy, and any analytics; fragments are not sent to the server, so a
|
||||
later server-side decision about a path is impossible; and DESIGN.md asks for real URLs.
|
||||
- **Module pages at the root (`/signage/…`).** Rejected: module ids would collide with platform
|
||||
namespaces, and reserving words would be a manifest-grammar change for a shorter address.
|
||||
- **A server catch-all that serves the page for any unknown `GET`.** Rejected: it would answer `200`
|
||||
for every mistyped API or asset path a browser requests, and it reverses the ADR 0044 posture that
|
||||
the API serves only paths it means to.
|
||||
- **Listing only the pages that exist (`/`, `/modules/*`).** Rejected: every Settings or Account page
|
||||
would then need a server change and every installation's proxy edited again.
|
||||
- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
|
||||
in every address people see, and makes a module a second-class destination beside Settings. The
|
||||
collision it avoided is handled instead by reserving the platform's names in Core.
|
||||
- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
|
||||
any analytics; fragments never reach the server, so no server-side decision about a path is
|
||||
possible; and DESIGN.md asks for real URLs.
|
||||
- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
|
||||
would become a Dashboard page, and the server would answer `200` for every mistyped address. It
|
||||
would remove the installed-module disclosure described above, at the cost of the server no longer
|
||||
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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
- 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`
|
||||
serving the page at every reserved namespace and refusing look-alikes, and the deploy smoke test
|
||||
reloading a module's address through the real split-topology proxy in a real browser.
|
||||
Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
|
||||
serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
|
||||
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
|
||||
Signage now chooses it once from the navigation, and can bookmark its address.
|
||||
- **Operators with their own proxy must forward the new paths.** An installation using the reference
|
||||
Caddyfiles gets them by copying the new examples; one with its own proxy must add `/modules`,
|
||||
`/settings`, `/account` and everything below each, or a refresh on those pages will be the proxy's
|
||||
`404`. The deployment guide says so.
|
||||
Signage now chooses it once from the navigation, and can bookmark `/signage`.
|
||||
- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
|
||||
is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
|
||||
- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
|
||||
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
|
||||
private; this is the same relationship `./dashboard-assets` already has.
|
||||
- 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
|
||||
|
||||
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
|
||||
it learns and changes its current sub-path through the module UI SDK, and what an address below
|
||||
`/modules/<id>/` means. That is a manifest and SDK contract change and its own ADR.
|
||||
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar
|
||||
(`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
|
||||
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
|
||||
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
|
||||
|
||||
@@ -260,12 +260,14 @@ have no redirect sink in this pass. See
|
||||
[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
|
||||
is a real page address: the API serves the Dashboard page at `/` and anywhere under `/modules`,
|
||||
`/settings`, and `/account` (each bare and with everything below it), with the same bytes, validator,
|
||||
and headers as `/`. A deep link opened while signed out therefore shows the sign-in form at that
|
||||
address and opens its own page once the person signs in — still with no return value and no
|
||||
redirect. Any other path, including look-alikes such as `/settings-old`, remains the sanitized
|
||||
`404`.
|
||||
is a real page address: the API serves the Dashboard page at `/`, at `/settings` and `/account`, and
|
||||
at `/<module-id>` for every discovered module that declares a Dashboard UI (enabled or not) — each
|
||||
bare and with everything below it — with the same bytes, validator, and headers as `/`. A deep link
|
||||
opened while signed out therefore shows the sign-in form at that address and opens its own page once
|
||||
the person signs in — still with no return value and no redirect. Any other path — a module that is
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
@@ -231,7 +231,7 @@ if (hasModule) {
|
||||
// Dashboard page there, and the shell mounts the same module again (ADR 0057).
|
||||
const moduleUrl = await evaluate("location.href");
|
||||
check(
|
||||
moduleUrl === `${dashboardOrigin}/modules/${moduleId}`,
|
||||
moduleUrl === `${dashboardOrigin}/${moduleId}`,
|
||||
"opening the module put its own address in the address bar",
|
||||
);
|
||||
await navigate(moduleUrl);
|
||||
|
||||
@@ -88,8 +88,9 @@ async function edgeChecks() {
|
||||
"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).
|
||||
for (const deepLink of ["/modules/example", "/settings/displays/add", "/account"]) {
|
||||
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
|
||||
// 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}`);
|
||||
check(
|
||||
deepPage.status === 200 &&
|
||||
@@ -98,10 +99,15 @@ async function edgeChecks() {
|
||||
`GET ${deepLink} through the proxy is the same Dashboard page`,
|
||||
);
|
||||
}
|
||||
check(
|
||||
await refusedByProxy(`${dashboardOrigin}/settings-old`),
|
||||
"a look-alike of a Dashboard namespace is refused by the proxy",
|
||||
);
|
||||
// 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(
|
||||
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
|
||||
`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`);
|
||||
await script.arrayBuffer();
|
||||
@@ -510,6 +516,25 @@ async function moduleChecks() {
|
||||
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") {
|
||||
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
||||
if (expectation === "absent") {
|
||||
|
||||
@@ -543,9 +543,13 @@ recreate_api() {
|
||||
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() {
|
||||
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_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"
|
||||
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||
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}" ||
|
||||
fail "disabling the module removed its installed package."
|
||||
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||
|
||||
@@ -90,8 +90,8 @@ if (serve) {
|
||||
const context = await esbuild.context(buildOptions);
|
||||
await context.watch();
|
||||
// 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).
|
||||
// Development only: the API serves the page at exactly the Dashboard's reserved paths instead.
|
||||
// `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
|
||||
// Development only: the API serves the page only at its platform pages and installed modules'.
|
||||
const { host, port } = await context.serve({
|
||||
fallback: path.join(outputDirectory, "index.html"),
|
||||
servedir: outputDirectory,
|
||||
|
||||
@@ -342,7 +342,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
setState(resolved);
|
||||
}, []);
|
||||
|
||||
// A non-canonical spelling of a real page (`/modules/example/`) is corrected in place, adding no
|
||||
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
|
||||
// history entry, so what is bookmarked or shared next is the one canonical address.
|
||||
useEffect(() => {
|
||||
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
/**
|
||||
* The Dashboard's URL space (ADR 0057): which paths are Dashboard pages, and what each one means.
|
||||
* The Dashboard's URL space (ADR 0057): what each Dashboard path means.
|
||||
*
|
||||
* Framework-free and free of browser globals, because two programs read it. The shell parses the
|
||||
* address bar with it, and `apps/api` serves the Dashboard page at exactly the namespaces it lists,
|
||||
* so a refreshed or bookmarked deep link reaches the shell instead of a `404`. One list, imported by
|
||||
* both, is what keeps the server from serving a path the shell cannot interpret, or the shell from
|
||||
* linking to a path the server would refuse.
|
||||
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
|
||||
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
|
||||
*
|
||||
* Module pages and platform pages share one top-level namespace:
|
||||
*
|
||||
* ```text
|
||||
* / Overview
|
||||
* /settings[/…] Settings
|
||||
* /account[/…] the account area
|
||||
* /<module-id>[/…] a module's page, and later its own sub-pages
|
||||
* ```
|
||||
*
|
||||
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
|
||||
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
|
||||
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
|
||||
* root path never becomes one.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every namespace the Dashboard owns besides `/` itself. Reserved whole, including paths no page uses
|
||||
* yet, so that adding a Settings or Account page never needs another server or proxy change.
|
||||
*
|
||||
* None of these overlaps a namespace something else owns: `/api` (the API), `/display` (the display
|
||||
* host), and `/assets` (both hosts' scripts).
|
||||
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
|
||||
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
|
||||
* reserved module id in Core; `apps/api` tests hold the two lists together.
|
||||
*/
|
||||
export const DASHBOARD_PAGE_PREFIXES = Object.freeze([
|
||||
"/modules",
|
||||
"/settings",
|
||||
"/account",
|
||||
] as const);
|
||||
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
|
||||
|
||||
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
|
||||
export type DashboardRoute =
|
||||
@@ -30,16 +36,7 @@ export type DashboardRoute =
|
||||
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
|
||||
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
|
||||
|
||||
/** Whether the Dashboard page is served at this path: the root, or anything inside a reserved
|
||||
* namespace. Says nothing about whether a page exists there; the shell decides that. */
|
||||
export function isDashboardPagePath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/" ||
|
||||
DASHBOARD_PAGE_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||
)
|
||||
);
|
||||
}
|
||||
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
|
||||
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
@@ -53,12 +50,15 @@ function decodeSegment(segment: string): string | undefined {
|
||||
/**
|
||||
* Reads a path into a route.
|
||||
*
|
||||
* `/modules/<id>` names a module's page. The module id is only decoded here, never validated against
|
||||
* a pattern: the shell compares it with the navigation the server resolved, and an id that matches
|
||||
* no entry is an unavailable module, not a malformed URL. Everything below `/modules/<id>/` belongs
|
||||
* to that module's own pages, which need a contract that does not exist yet, so it is `not-found`
|
||||
* for now rather than silently showing the module's first page. One trailing slash is tolerated, as
|
||||
* a person typing a URL expects; the shell then replaces it with the canonical spelling.
|
||||
* A single top-level segment that is not a platform page names a module's page. The id is only
|
||||
* decoded here, never validated against a pattern: the shell compares it with the navigation the
|
||||
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
|
||||
*
|
||||
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
|
||||
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
|
||||
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
|
||||
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
|
||||
* canonical spelling.
|
||||
*/
|
||||
export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
if (pathname === "/") {
|
||||
@@ -69,11 +69,12 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
|
||||
if (meaningful.length === 2 && meaningful[0] === "modules") {
|
||||
const moduleId = decodeSegment(meaningful[1] ?? "");
|
||||
if (moduleId !== undefined) {
|
||||
return { kind: "module", moduleId };
|
||||
}
|
||||
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
|
||||
if (first === undefined || platformPages.has(first)) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
if (meaningful.length === 1) {
|
||||
return { kind: "module", moduleId: first };
|
||||
}
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
@@ -84,5 +85,5 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
if (route.kind === "overview") {
|
||||
return "/";
|
||||
}
|
||||
return `/modules/${encodeURIComponent(route.moduleId)}`;
|
||||
return `/${encodeURIComponent(route.moduleId)}`;
|
||||
}
|
||||
|
||||
@@ -4,60 +4,17 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DASHBOARD_PAGE_PREFIXES,
|
||||
DASHBOARD_PLATFORM_PAGES,
|
||||
dashboardPath,
|
||||
isDashboardPagePath,
|
||||
parseDashboardPath,
|
||||
} from "../src/dashboard-routes.js";
|
||||
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
describe("the Dashboard URL space", () => {
|
||||
it("reserves exactly the platform's page namespaces, frozen", () => {
|
||||
expect(Object.isFrozen(DASHBOARD_PAGE_PREFIXES)).toBe(true);
|
||||
expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/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);
|
||||
}
|
||||
describe("the Dashboard's own top-level pages", () => {
|
||||
it("are exactly Settings and Account, frozen", () => {
|
||||
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
|
||||
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,40 +23,34 @@ describe("parseDashboardPath", () => {
|
||||
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
|
||||
});
|
||||
|
||||
it("names a module page by its id, tolerating one trailing slash", () => {
|
||||
expect(parseDashboardPath("/modules/signage")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
});
|
||||
expect(parseDashboardPath("/modules/signage/")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
});
|
||||
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
|
||||
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
|
||||
});
|
||||
|
||||
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
|
||||
expect(parseDashboardPath("/modules/sign%61ge")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
});
|
||||
expect(parseDashboardPath("/sign%61ge")).toEqual({ 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", () => {
|
||||
for (const pathname of [
|
||||
"",
|
||||
"/modules",
|
||||
"/modules/",
|
||||
"/modules//",
|
||||
"//modules/signage",
|
||||
"/modules/signage//",
|
||||
"//",
|
||||
"//signage",
|
||||
"/signage//",
|
||||
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
|
||||
"/modules/signage/timelines",
|
||||
"/modules/%E0%A4%A",
|
||||
"/settings",
|
||||
"/settings/displays",
|
||||
"/account",
|
||||
"/overview",
|
||||
"/index.html",
|
||||
"/signage/timelines",
|
||||
"/signage/timelines/abc",
|
||||
"/%E0%A4%A",
|
||||
"/settings/displays/add",
|
||||
"signage",
|
||||
]) {
|
||||
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
|
||||
}
|
||||
@@ -107,13 +58,13 @@ describe("parseDashboardPath", () => {
|
||||
});
|
||||
|
||||
describe("dashboardPath", () => {
|
||||
it("spells each route canonically", () => {
|
||||
it("spells each route canonically, with no implementation prefix", () => {
|
||||
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", () => {
|
||||
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({
|
||||
kind: "module",
|
||||
moduleId: "a/../b",
|
||||
@@ -126,7 +77,6 @@ describe("dashboardPath", () => {
|
||||
{ kind: "module", moduleId: "example" } as const,
|
||||
]) {
|
||||
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
|
||||
expect(isDashboardPagePath(dashboardPath(route))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,7 +398,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
root.render(
|
||||
<HostApp
|
||||
config={config}
|
||||
history={createMemoryHostHistory("/modules/announcements")}
|
||||
history={createMemoryHostHistory("/announcements")}
|
||||
importModule={importer}
|
||||
/>,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||
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 () => {
|
||||
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
|
||||
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||
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 () => {
|
||||
let releaseSignOut: (() => void) | undefined;
|
||||
@@ -938,7 +938,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
||||
await render(
|
||||
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
|
||||
async () => artifact("livestream", "2.0.0"),
|
||||
"/modules/livestream",
|
||||
"/livestream",
|
||||
);
|
||||
|
||||
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
|
||||
@@ -954,7 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
|
||||
requested.push(url);
|
||||
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
|
||||
},
|
||||
"/modules/video",
|
||||
"/video",
|
||||
);
|
||||
|
||||
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
|
||||
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/modules/example");
|
||||
window.history.replaceState(null, "", "/example");
|
||||
});
|
||||
it("keeps fixed shell tracks while module content changes size", async () => {
|
||||
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
|
||||
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/modules/example");
|
||||
window.history.replaceState(null, "", "/example");
|
||||
});
|
||||
it("renders its own error when the artifact cannot be imported", async () => {
|
||||
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
|
||||
|
||||
@@ -14,21 +14,21 @@ describe("the browser host history", () => {
|
||||
const history = createBrowserHostHistory(window);
|
||||
const lengthBefore = window.history.length;
|
||||
|
||||
history.push("/modules/example");
|
||||
history.push("/example");
|
||||
|
||||
expect(history.pathname()).toBe("/modules/example");
|
||||
expect(window.location.pathname).toBe("/modules/example");
|
||||
expect(history.pathname()).toBe("/example");
|
||||
expect(window.location.pathname).toBe("/example");
|
||||
expect(window.history.length).toBe(lengthBefore + 1);
|
||||
});
|
||||
|
||||
it("adds no entry when the page asked for is already open", () => {
|
||||
const history = createBrowserHostHistory(window);
|
||||
history.push("/modules/example");
|
||||
history.push("/example");
|
||||
const length = window.history.length;
|
||||
const listener = vi.fn();
|
||||
history.subscribe(listener);
|
||||
|
||||
history.push("/modules/example");
|
||||
history.push("/example");
|
||||
|
||||
expect(window.history.length).toBe(length);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
@@ -36,12 +36,12 @@ describe("the browser host history", () => {
|
||||
|
||||
it("corrects the current entry in place with replace", () => {
|
||||
const history = createBrowserHostHistory(window);
|
||||
history.push("/modules/example/");
|
||||
history.push("/example/");
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -50,14 +50,14 @@ describe("the browser host history", () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = history.subscribe(listener);
|
||||
|
||||
history.push("/modules/a");
|
||||
history.replace("/modules/b");
|
||||
history.push("/a");
|
||||
history.replace("/b");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
|
||||
unsubscribe();
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
history.push("/modules/c");
|
||||
history.push("/c");
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -92,28 +92,28 @@ describe("the browser host history", () => {
|
||||
};
|
||||
const history = createBrowserHostHistory(view);
|
||||
|
||||
history.push("/modules/a");
|
||||
history.replace("/modules/b");
|
||||
history.push("/a");
|
||||
history.replace("/b");
|
||||
|
||||
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/modules/a");
|
||||
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/modules/b");
|
||||
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/a");
|
||||
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the in-memory host history", () => {
|
||||
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
|
||||
const history = createMemoryHostHistory("/");
|
||||
history.push("/modules/a");
|
||||
history.push("/modules/b");
|
||||
history.push("/a");
|
||||
history.push("/b");
|
||||
history.go(-1);
|
||||
expect(history.pathname()).toBe("/modules/a");
|
||||
expect(history.pathname()).toBe("/a");
|
||||
|
||||
history.push("/modules/c");
|
||||
expect(history.entries).toEqual(["/", "/modules/a", "/modules/c"]);
|
||||
history.push("/c");
|
||||
expect(history.entries).toEqual(["/", "/a", "/c"]);
|
||||
|
||||
history.go(-10);
|
||||
expect(history.pathname()).toBe("/");
|
||||
history.go(10);
|
||||
expect(history.pathname()).toBe("/modules/c");
|
||||
expect(history.pathname()).toBe("/c");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
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(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 () => {
|
||||
const { imported } = await render("/modules/signage");
|
||||
const { imported } = await render("/signage");
|
||||
|
||||
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]);
|
||||
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 () => {
|
||||
const { imported, titles } = await render("/modules/disabled-module");
|
||||
const { imported, titles } = await render("/disabled-module");
|
||||
|
||||
expect(query("host-module-unavailable")).not.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 () => {
|
||||
for (const path of ["/settings/displays", "/modules/calendar/events", "/account"]) {
|
||||
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
|
||||
await act(async () => {
|
||||
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 () => {
|
||||
const { history, imported } = await render("/modules/calendar/");
|
||||
it("addresses module pages at the top level, beside the platform's own pages, and never confuses the two", async () => {
|
||||
// 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);
|
||||
});
|
||||
|
||||
it("opens the deep link it was given once the person signs in, with no redirect", async () => {
|
||||
const history = createMemoryHostHistory("/modules/calendar");
|
||||
const { imported } = await render("/modules/calendar", { authenticated: false, history });
|
||||
const history = createMemoryHostHistory("/calendar");
|
||||
const { imported } = await render("/calendar", { authenticated: false, history });
|
||||
expect(query("host-login")).not.toBeNull();
|
||||
|
||||
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(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 [
|
||||
["select-overview", "/"],
|
||||
["select-signage", "/modules/signage"],
|
||||
["select-calendar", "/modules/calendar"],
|
||||
["select-signage", "/signage"],
|
||||
["select-calendar", "/calendar"],
|
||||
] as const) {
|
||||
const link = query(testId);
|
||||
expect(link?.tagName, testId).toBe("A");
|
||||
@@ -266,7 +277,7 @@ describe("moving between pages", () => {
|
||||
const event = await click(query("select-calendar"));
|
||||
|
||||
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(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 () => {
|
||||
const { history } = await render("/modules/calendar");
|
||||
const { history } = await render("/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 () => {
|
||||
@@ -319,11 +330,11 @@ describe("moving between pages", () => {
|
||||
});
|
||||
|
||||
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"));
|
||||
|
||||
expect(history.entries).toEqual(["/modules/missing", "/"]);
|
||||
expect(history.entries).toEqual(["/missing", "/"]);
|
||||
expect(query("host-overview")).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
+50
-7
@@ -188,12 +188,13 @@ describe("reference deployment compose files", () => {
|
||||
.filter((line) => !line.trim().startsWith("#"))
|
||||
.join("\n");
|
||||
|
||||
// The Dashboard's reserved namespaces (ADR 0057, `DASHBOARD_PAGE_PREFIXES` in
|
||||
// `surfaces/shell/src/dashboard-routes.ts`), each both bare and with everything below it, so a
|
||||
// refreshed deep link reaches the API instead of the proxy's 404.
|
||||
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
|
||||
// The Dashboard's other pages (ADR 0057): every top-level name shaped like a module id, and
|
||||
// nothing else, so the API — which knows the installed modules — decides page or 404.
|
||||
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 -CF-Connecting-IP");
|
||||
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", () => {
|
||||
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");
|
||||
@@ -213,9 +254,11 @@ describe("split-origin reference proxy", () => {
|
||||
.filter((line) => !line.trim().startsWith("#"))
|
||||
.join("\n");
|
||||
|
||||
expect(directives).toContain(
|
||||
"@dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/*",
|
||||
);
|
||||
expect(directives).toContain("@dashboard path / /assets/ekklesia-dashboard/*");
|
||||
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.
|
||||
expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user