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
+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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user