Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
isVersionCompatible,
|
||||
type ModuleNavigationDeclaration,
|
||||
moduleManifestNavigation,
|
||||
type PermissionDeclaration,
|
||||
type PermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -24,10 +26,18 @@ export interface PublishableModuleUi {
|
||||
*
|
||||
* Carried here so a host can render something a person recognizes instead of a module id. It is
|
||||
* presentation *identity*, not presentation *placement*: a module says what it is called, while
|
||||
* where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping,
|
||||
* ordering, or navigation belongs on this record.
|
||||
* where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping
|
||||
* or ordering belongs on this record.
|
||||
*/
|
||||
readonly name: string;
|
||||
/**
|
||||
* What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon it is
|
||||
* drawn with and its primary pages, in its declared order. Like `name`, this is the module's own
|
||||
* identity — what it is and which pages it has — never where it sits in the Dashboard, which stays
|
||||
* the administrator's (ADR 0027). Empty for a manifest v8 module and for one that declares none:
|
||||
* the generic icon and a single root page.
|
||||
*/
|
||||
readonly navigation: ModuleNavigationDeclaration;
|
||||
readonly artifactPath: string;
|
||||
/**
|
||||
* The module's own manifest-declared permissions, carried here so a publisher can compute the
|
||||
@@ -44,6 +54,18 @@ export interface PublishableModuleUi {
|
||||
readonly permissions: readonly PermissionDeclaration[];
|
||||
}
|
||||
|
||||
/** A fresh, deeply frozen copy, so a caller holding a record never reaches the manifest graph. */
|
||||
function frozenNavigation(navigation: ModuleNavigationDeclaration): ModuleNavigationDeclaration {
|
||||
return Object.freeze({
|
||||
...(navigation.icon === undefined ? {} : { icon: navigation.icon }),
|
||||
pages: Object.freeze(
|
||||
navigation.pages.map((page) =>
|
||||
Object.freeze({ id: page.id, label: page.label, path: page.path }),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed module whose dedicated full-viewport display artifact may be published.
|
||||
*
|
||||
@@ -233,6 +255,7 @@ export function createModuleUiPublicationRegistry(
|
||||
artifactPath,
|
||||
moduleId: module.manifest.id,
|
||||
name: module.manifest.name,
|
||||
navigation: frozenNavigation(moduleManifestNavigation(module.manifest)),
|
||||
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
||||
// this record can never reach the manifest object graph itself.
|
||||
permissions: Object.freeze([...module.manifest.permissions]),
|
||||
|
||||
@@ -219,20 +219,25 @@ describe("Dashboard grouping never becomes module structure", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the publication record free of any grouping, ordering, or navigation field", async () => {
|
||||
it("keeps the publication record free of any grouping or ordering field", async () => {
|
||||
const registry = createModuleUiPublicationRegistry(await discoverFixtures());
|
||||
|
||||
const published = registry.resolve("livestream", "1.0.0");
|
||||
|
||||
// The record a host mounts from carries identity, the validated artifact, declared permissions,
|
||||
// and the module's own name — never a group, an order, a parent, or a folder.
|
||||
// the module's own name, and its own declared navigation — never a group, an order, a parent,
|
||||
// or a folder. Navigation is what the module *is* (its icon and pages, ADR 0063); where it sits
|
||||
// stays the administrator's (ADR 0027).
|
||||
expect(Object.keys(published ?? {}).sort()).toEqual([
|
||||
"artifactPath",
|
||||
"moduleId",
|
||||
"name",
|
||||
"navigation",
|
||||
"permissions",
|
||||
"version",
|
||||
]);
|
||||
expect(Object.keys(published?.navigation ?? {}).sort()).toEqual(["pages"]);
|
||||
expect(published?.navigation.pages).toEqual([]);
|
||||
});
|
||||
|
||||
it("takes the display name from the module's own manifest, not from any folder", async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { moduleManifestNavigation } from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
@@ -1220,3 +1222,107 @@ describe("browser contribution artifact resolution", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifest v8 and v9 side by side (ADR 0063)", () => {
|
||||
const releasedSignageManifest = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"packages",
|
||||
"contracts",
|
||||
"tests",
|
||||
"fixtures",
|
||||
"released-v8-signage-0.1.5.module.json",
|
||||
);
|
||||
|
||||
it("discovers the released Signage 0.1.5 module exactly as published, with no navigation", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const manifest = JSON.parse(await readFile(releasedSignageManifest, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
await createModule(modulesDirectory, "signage", {
|
||||
extraFiles: {
|
||||
"dist/display/index.js": "export default {};\n",
|
||||
"dist/schema/index.js": "export {};\n",
|
||||
"dist/ui/index.js": "export default {};\n",
|
||||
"drizzle/meta/_journal.json": JSON.stringify({
|
||||
dialect: "postgresql",
|
||||
entries: [],
|
||||
version: "7",
|
||||
}),
|
||||
},
|
||||
manifest,
|
||||
packageJson: {
|
||||
ekklesia: { layer: "module" },
|
||||
name: "@zayin/signage",
|
||||
type: "module",
|
||||
version: manifest.version,
|
||||
},
|
||||
serverSource: 'export default { id: "signage", version: "0.1.5" };',
|
||||
});
|
||||
|
||||
const [signage] = await discoverModules({
|
||||
moduleSdkVersion: "0.2.5",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.2.5",
|
||||
});
|
||||
|
||||
expect(signage?.manifest.manifestVersion).toBe(8);
|
||||
expect(signage?.entrypoints.ui).toBeDefined();
|
||||
expect(moduleManifestNavigation(signage?.manifest ?? { manifestVersion: 8 })).toEqual({
|
||||
pages: [],
|
||||
});
|
||||
// Its settings, which Settings → Modules lists (ADR 0061), are read exactly as before.
|
||||
expect(signage?.manifest.configuration.map((declaration) => declaration.key)).toEqual(
|
||||
(manifest.configuration as { key: string }[]).map((declaration) => declaration.key),
|
||||
);
|
||||
});
|
||||
|
||||
it("discovers a manifest v9 module with its declared icon and pages", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "media", {
|
||||
extraFiles: { "dist/ui/index.js": "export default {};\n" },
|
||||
manifest: {
|
||||
...manifestFor("media"),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
manifestVersion: 9,
|
||||
navigation: {
|
||||
icon: "images",
|
||||
pages: [
|
||||
{ id: "sermons", label: "Sermons", path: "sermons" },
|
||||
{ id: "library", label: "Library", path: "library" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [media] = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(media?.manifest.manifestVersion).toBe(9);
|
||||
const navigation = moduleManifestNavigation(media?.manifest ?? { manifestVersion: 9 });
|
||||
expect(navigation.icon).toBe("images");
|
||||
expect(navigation.pages.map((page) => page.path)).toEqual(["sermons", "library"]);
|
||||
// Deeply frozen like the rest of a discovered manifest.
|
||||
expect(Object.isFrozen(navigation.pages)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a manifest v9 module whose navigation escapes its own namespace", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "media", {
|
||||
extraFiles: { "dist/ui/index.js": "export default {};\n" },
|
||||
manifest: {
|
||||
...manifestFor("media"),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
manifestVersion: 9,
|
||||
navigation: { pages: [{ id: "out", label: "Out", path: "../settings" }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(issueCodes(await discoveryError(modulesDirectory))).toEqual(["MODULE_MANIFEST_INVALID"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user