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
@@ -9,6 +9,7 @@ export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
export * from "./module-migration.js";
|
||||
export * from "./module-navigation.js";
|
||||
export * from "./module-realtime.js";
|
||||
export * from "./module-runtime.js";
|
||||
export * from "./module-schema-metadata.js";
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
type ModuleContributionDeclaration,
|
||||
moduleContributionDeclarationSchema,
|
||||
} from "./module-contribution.js";
|
||||
import {
|
||||
EMPTY_MODULE_NAVIGATION,
|
||||
type ModuleNavigationDeclaration,
|
||||
moduleNavigationDeclarationSchema,
|
||||
} from "./module-navigation.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||
@@ -130,9 +135,21 @@ function reportDuplicates(
|
||||
}
|
||||
|
||||
/**
|
||||
* The one manifest shape Core accepts. Version 7's strict top-level object rejects `database`, so
|
||||
* the static ABI declaration is an incompatible shape change and the canonical manifest moves to
|
||||
* `manifestVersion: 8` without silently reinterpreting version 7.
|
||||
* The manifest shapes Core accepts: **version 8 and version 9**, each read strictly as itself.
|
||||
*
|
||||
* Version 7's strict top-level object rejected `database`, so that ABI declaration moved the
|
||||
* manifest to version 8 (ADR 0050). Version 8's strict object likewise rejects `navigation`, so
|
||||
* module-declared Dashboard navigation — an icon and primary pages (ADR 0063) — is version 9.
|
||||
*
|
||||
* Unlike every earlier step, **version 8 stays accepted beside version 9** (ADR 0063). Released
|
||||
* modules were packaged, verified, and installed as version 8, and the navigation version adds one
|
||||
* optional field and changes the meaning of nothing a version 8 document says. Requiring every
|
||||
* installation's modules to be rebuilt just because Core gained navigation would break working
|
||||
* churches for no safety gain. The rule that made single-version acceptance matter still holds
|
||||
* exactly: `manifestVersion` selects one strict schema, so a document is only ever read as the one
|
||||
* version it declares — a version 8 document that names `navigation` is refused, never quietly
|
||||
* reinterpreted as version 9 — and nothing ever rewrites a released manifest, its package bytes, or
|
||||
* its digest.
|
||||
*
|
||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||
@@ -141,219 +158,267 @@ function reportDuplicates(
|
||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||
* importing a single line of module code, exactly as they always have.
|
||||
*
|
||||
* Version 7, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 7
|
||||
* by version 8 (ADR 0050). One manifest document can therefore never be read as two different
|
||||
* versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade.
|
||||
* Every other version fails validation: there is no conversion and no guessing. Version 7 and
|
||||
* earlier must be rebuilt; omitting `database` selects the legacy facade.
|
||||
*/
|
||||
export const MODULE_MANIFEST_VERSION = 8;
|
||||
export const SUPPORTED_MODULE_MANIFEST_VERSIONS = Object.freeze([8, 9] as const);
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
/** The newest manifest version: what a new module declares, and what the template uses. */
|
||||
export const MODULE_MANIFEST_VERSION = 9;
|
||||
|
||||
const manifestFields = {
|
||||
id: moduleIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500).optional(),
|
||||
version: semanticVersionSchema,
|
||||
compatibility: z
|
||||
.object({
|
||||
platform: semanticVersionRangeSchema,
|
||||
moduleSdk: semanticVersionRangeSchema,
|
||||
})
|
||||
.strict(),
|
||||
dependencies: dependenciesSchema,
|
||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
||||
/** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert
|
||||
* data: declaring configuration never causes Core to read a database, contact a host, or run
|
||||
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
database: moduleDatabaseDeclarationSchema.optional(),
|
||||
/** Compiled browser renderers this module offers to capabilities **other** modules provide
|
||||
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
|
||||
* never learns what a contribution renders. A module that contributes nothing omits the field. */
|
||||
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
|
||||
entrypoints: entrypointsSchema,
|
||||
};
|
||||
|
||||
const manifestFieldsObject = z.object(manifestFields);
|
||||
|
||||
/** The fields every supported version shares, after parsing. */
|
||||
type SharedManifestFields = z.output<typeof manifestFieldsObject>;
|
||||
|
||||
/** The cross-field rules every supported version obeys, identically. */
|
||||
function refineManifest(manifest: SharedManifestFields, context: z.RefinementCtx): void {
|
||||
reportDuplicates(
|
||||
manifest.capabilities.map((capability) => capability.id),
|
||||
["capabilities"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.permissions.map((permission) => permission.id),
|
||||
["permissions"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||
["storage", "buckets"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
// One namespace for every configuration key a module owns, so an ordinary key and a secret key
|
||||
// can never collide: a module cannot declare `apiToken` as both a defaultable string and a
|
||||
// deployment-supplied secret and leave the platform to guess which one a stored row means.
|
||||
reportDuplicates(
|
||||
manifest.configuration.map((declaration) => declaration.key),
|
||||
["configuration"],
|
||||
context,
|
||||
"key",
|
||||
);
|
||||
|
||||
manifest.capabilities.forEach((capability, index) => {
|
||||
if (!capability.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["capabilities", index, "id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
reportDuplicates(
|
||||
manifest.contributions.map((contribution) => contribution.id),
|
||||
["contributions"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
|
||||
const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id));
|
||||
const contributionArtifacts = new Set<string>();
|
||||
manifest.contributions.forEach((contribution, index) => {
|
||||
// Namespacing is what makes a contribution id globally unique without a registry: module ids
|
||||
// are already unique, so two modules can never produce the same contribution id.
|
||||
if (!contribution.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["contributions", index, "id"],
|
||||
});
|
||||
}
|
||||
// A contribution extends *another* module's capability. Targeting one this module already
|
||||
// provides would be a module contributing to itself, which needs no platform mechanism at all
|
||||
// and would make the compatibility check meaningless.
|
||||
if (ownedCapabilityIds.has(contribution.capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A browser contribution must target a capability another module provides.",
|
||||
path: ["contributions", index, "capability"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
Object.entries(manifest.entrypoints).some(([entrypointName, configured]) =>
|
||||
entrypointName === "display"
|
||||
? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact
|
||||
: configured === contribution.artifact,
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A contribution artifact must use a different path from every entrypoint.",
|
||||
path: ["contributions", index, "artifact"],
|
||||
});
|
||||
}
|
||||
if (contributionArtifacts.has(contribution.artifact)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Two contributions must not declare the same artifact path.",
|
||||
path: ["contributions", index, "artifact"],
|
||||
});
|
||||
}
|
||||
contributionArtifacts.add(contribution.artifact);
|
||||
});
|
||||
|
||||
manifest.permissions.forEach((permission, index) => {
|
||||
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["permissions", index, "id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const display = manifest.entrypoints.display;
|
||||
if (display !== undefined) {
|
||||
const displayPermission = manifest.permissions.find(
|
||||
(permission) => permission.id === display.permission,
|
||||
);
|
||||
if (displayPermission === undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A display entrypoint must reference a permission declared by this module.",
|
||||
path: ["entrypoints", "display", "permission"],
|
||||
});
|
||||
} else if (displayPermission.baseline === "public") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A display entrypoint permission must require an authenticated principal.",
|
||||
path: ["entrypoints", "display", "permission"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
Object.entries(manifest.entrypoints).some(
|
||||
([entrypointName, configured]) =>
|
||||
entrypointName !== "display" && configured === display.artifact,
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "The display UI must use a different path from every other configured entrypoint.",
|
||||
path: ["entrypoints", "display", "artifact"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module cannot depend on itself.",
|
||||
path: ["dependencies", "modules", dependencyKind, manifest.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) {
|
||||
if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${dependencyId} cannot be both a required and optional module dependency.`,
|
||||
path: ["dependencies", "modules", "optional", dependencyId],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
||||
if (ownedCapabilityIds.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module does not need to depend on a capability it owns.",
|
||||
path: ["dependencies", "capabilities", dependencyKind, capability],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const capability of Object.keys(manifest.dependencies.capabilities.required)) {
|
||||
if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${capability} cannot be both a required and optional capability dependency.`,
|
||||
path: ["dependencies", "capabilities", "optional", capability],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const moduleManifestV8Schema = z
|
||||
.object({ manifestVersion: z.literal(8), ...manifestFields })
|
||||
.strict()
|
||||
.superRefine(refineManifest);
|
||||
|
||||
const moduleManifestV9Schema = z
|
||||
.object({
|
||||
manifestVersion: z.literal(MODULE_MANIFEST_VERSION),
|
||||
id: moduleIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500).optional(),
|
||||
version: semanticVersionSchema,
|
||||
compatibility: z
|
||||
.object({
|
||||
platform: semanticVersionRangeSchema,
|
||||
moduleSdk: semanticVersionRangeSchema,
|
||||
})
|
||||
.strict(),
|
||||
dependencies: dependenciesSchema,
|
||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
||||
/** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert
|
||||
* data: declaring configuration never causes Core to read a database, contact a host, or run
|
||||
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
database: moduleDatabaseDeclarationSchema.optional(),
|
||||
/** Compiled browser renderers this module offers to capabilities **other** modules provide
|
||||
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
|
||||
* never learns what a contribution renders. A module that contributes nothing omits the field. */
|
||||
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
|
||||
entrypoints: entrypointsSchema,
|
||||
manifestVersion: z.literal(9),
|
||||
...manifestFields,
|
||||
/** The module's Dashboard navigation (ADR 0063): its Lucide icon and primary pages. Omitted,
|
||||
* the module is drawn with the generic icon and has one page, its root — exactly as a version 8
|
||||
* module. Only meaningful for a module with a primary UI. */
|
||||
navigation: moduleNavigationDeclarationSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, context) => {
|
||||
reportDuplicates(
|
||||
manifest.capabilities.map((capability) => capability.id),
|
||||
["capabilities"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.permissions.map((permission) => permission.id),
|
||||
["permissions"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||
["storage", "buckets"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
// One namespace for every configuration key a module owns, so an ordinary key and a secret key
|
||||
// can never collide: a module cannot declare `apiToken` as both a defaultable string and a
|
||||
// deployment-supplied secret and leave the platform to guess which one a stored row means.
|
||||
reportDuplicates(
|
||||
manifest.configuration.map((declaration) => declaration.key),
|
||||
["configuration"],
|
||||
context,
|
||||
"key",
|
||||
);
|
||||
|
||||
manifest.capabilities.forEach((capability, index) => {
|
||||
if (!capability.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["capabilities", index, "id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
reportDuplicates(
|
||||
manifest.contributions.map((contribution) => contribution.id),
|
||||
["contributions"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
|
||||
const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id));
|
||||
const contributionArtifacts = new Set<string>();
|
||||
manifest.contributions.forEach((contribution, index) => {
|
||||
// Namespacing is what makes a contribution id globally unique without a registry: module ids
|
||||
// are already unique, so two modules can never produce the same contribution id.
|
||||
if (!contribution.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["contributions", index, "id"],
|
||||
});
|
||||
}
|
||||
// A contribution extends *another* module's capability. Targeting one this module already
|
||||
// provides would be a module contributing to itself, which needs no platform mechanism at all
|
||||
// and would make the compatibility check meaningless.
|
||||
if (ownedCapabilityIds.has(contribution.capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A browser contribution must target a capability another module provides.",
|
||||
path: ["contributions", index, "capability"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
Object.entries(manifest.entrypoints).some(([entrypointName, configured]) =>
|
||||
entrypointName === "display"
|
||||
? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact
|
||||
: configured === contribution.artifact,
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A contribution artifact must use a different path from every entrypoint.",
|
||||
path: ["contributions", index, "artifact"],
|
||||
});
|
||||
}
|
||||
if (contributionArtifacts.has(contribution.artifact)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Two contributions must not declare the same artifact path.",
|
||||
path: ["contributions", index, "artifact"],
|
||||
});
|
||||
}
|
||||
contributionArtifacts.add(contribution.artifact);
|
||||
});
|
||||
|
||||
manifest.permissions.forEach((permission, index) => {
|
||||
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["permissions", index, "id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const display = manifest.entrypoints.display;
|
||||
if (display !== undefined) {
|
||||
const displayPermission = manifest.permissions.find(
|
||||
(permission) => permission.id === display.permission,
|
||||
);
|
||||
if (displayPermission === undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A display entrypoint must reference a permission declared by this module.",
|
||||
path: ["entrypoints", "display", "permission"],
|
||||
});
|
||||
} else if (displayPermission.baseline === "public") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A display entrypoint permission must require an authenticated principal.",
|
||||
path: ["entrypoints", "display", "permission"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
Object.entries(manifest.entrypoints).some(
|
||||
([entrypointName, configured]) =>
|
||||
entrypointName !== "display" && configured === display.artifact,
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"The display UI must use a different path from every other configured entrypoint.",
|
||||
path: ["entrypoints", "display", "artifact"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module cannot depend on itself.",
|
||||
path: ["dependencies", "modules", dependencyKind, manifest.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) {
|
||||
if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${dependencyId} cannot be both a required and optional module dependency.`,
|
||||
path: ["dependencies", "modules", "optional", dependencyId],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
||||
if (ownedCapabilityIds.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module does not need to depend on a capability it owns.",
|
||||
path: ["dependencies", "capabilities", dependencyKind, capability],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const capability of Object.keys(manifest.dependencies.capabilities.required)) {
|
||||
if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${capability} cannot be both a required and optional capability dependency.`,
|
||||
path: ["dependencies", "capabilities", "optional", capability],
|
||||
});
|
||||
}
|
||||
refineManifest(manifest, context);
|
||||
if (manifest.navigation !== undefined && manifest.entrypoints.ui === undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Navigation can only be declared by a module with a primary UI (entrypoints.ui).",
|
||||
path: ["navigation"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const moduleManifestSchema = z.discriminatedUnion("manifestVersion", [
|
||||
moduleManifestV8Schema,
|
||||
moduleManifestV9Schema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The navigation a manifest declares, normalized: version 8 declares none, so it gets no icon and
|
||||
* no pages, exactly like a version 9 manifest that omits the field. Read through this rather than
|
||||
* from the manifest, so no consumer branches on the version.
|
||||
*/
|
||||
export function moduleManifestNavigation(manifest: {
|
||||
readonly manifestVersion: number;
|
||||
readonly navigation?: ModuleNavigationDeclaration | undefined;
|
||||
}): ModuleNavigationDeclaration {
|
||||
return manifest.manifestVersion === 9 && manifest.navigation !== undefined
|
||||
? manifest.navigation
|
||||
: EMPTY_MODULE_NAVIGATION;
|
||||
}
|
||||
|
||||
export function parseModuleManifest(input: unknown): ModuleManifest {
|
||||
return moduleManifestSchema.parse(input);
|
||||
}
|
||||
@@ -364,6 +429,8 @@ export function safeParseModuleManifest(input: unknown) {
|
||||
|
||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleManifestV8 = z.output<typeof moduleManifestV8Schema>;
|
||||
export type ModuleManifestV9 = z.output<typeof moduleManifestV9Schema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
/** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape
|
||||
* Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* A module's Dashboard navigation (manifest v9, ADR 0063): the icon it is drawn with, and the
|
||||
* meaningful primary pages it contributes to the global sidebar beneath its own entry.
|
||||
*
|
||||
* Inert data, like every other manifest field. The host resolves the icon and draws the pages; a
|
||||
* manifest never supplies executable icon code, a URL, or a route outside its own namespace.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Lucide icons a module may declare (DESIGN.md locked decision 94, ADR 0058), by their stable
|
||||
* Lucide names.
|
||||
*
|
||||
* A curated set rather than "any Lucide name", for two reasons. The host imports exactly these by
|
||||
* name, so the Dashboard never bundles the whole library to honour one manifest. And a manifest is
|
||||
* validated against it, so a typo is caught when a module is packaged or discovered instead of
|
||||
* silently drawing the generic icon. Growing the set is an additive platform change: the shell's
|
||||
* tests hold its icon map equal to this list, so a name cannot be added here without the Dashboard
|
||||
* being able to draw it.
|
||||
*
|
||||
* Generic subjects only. There is no "signage" or "calendar" entry tied to a particular module: a
|
||||
* module chooses what represents it.
|
||||
*/
|
||||
export const MODULE_NAVIGATION_ICONS = Object.freeze([
|
||||
"activity",
|
||||
"archive",
|
||||
"baby",
|
||||
"bell",
|
||||
"book-open",
|
||||
"book-open-text",
|
||||
"bookmark",
|
||||
"briefcase",
|
||||
"building",
|
||||
"bus",
|
||||
"calendar",
|
||||
"calendar-check",
|
||||
"calendar-days",
|
||||
"camera",
|
||||
"car",
|
||||
"chart-column",
|
||||
"chart-pie",
|
||||
"church",
|
||||
"clipboard-check",
|
||||
"clipboard-list",
|
||||
"clock",
|
||||
"coffee",
|
||||
"contact",
|
||||
"credit-card",
|
||||
"database",
|
||||
"door-open",
|
||||
"file-text",
|
||||
"files",
|
||||
"film",
|
||||
"flag",
|
||||
"folder",
|
||||
"gift",
|
||||
"globe",
|
||||
"graduation-cap",
|
||||
"hand-coins",
|
||||
"hand-heart",
|
||||
"handshake",
|
||||
"headphones",
|
||||
"heart",
|
||||
"heart-handshake",
|
||||
"house",
|
||||
"image",
|
||||
"images",
|
||||
"inbox",
|
||||
"key-round",
|
||||
"landmark",
|
||||
"layout-grid",
|
||||
"library",
|
||||
"list-checks",
|
||||
"list-todo",
|
||||
"mail",
|
||||
"map",
|
||||
"map-pin",
|
||||
"megaphone",
|
||||
"message-circle",
|
||||
"message-square",
|
||||
"mic",
|
||||
"monitor",
|
||||
"monitor-play",
|
||||
"music",
|
||||
"newspaper",
|
||||
"notebook",
|
||||
"package",
|
||||
"palette",
|
||||
"party-popper",
|
||||
"phone",
|
||||
"presentation",
|
||||
"printer",
|
||||
"projector",
|
||||
"radio",
|
||||
"receipt",
|
||||
"school",
|
||||
"send",
|
||||
"shield",
|
||||
"shopping-bag",
|
||||
"sparkles",
|
||||
"star",
|
||||
"sun",
|
||||
"tablet-smartphone",
|
||||
"tag",
|
||||
"tent",
|
||||
"ticket",
|
||||
"trophy",
|
||||
"truck",
|
||||
"tv",
|
||||
"user-check",
|
||||
"user-round",
|
||||
"users",
|
||||
"users-round",
|
||||
"utensils",
|
||||
"video",
|
||||
"wallet",
|
||||
"wrench",
|
||||
] as const);
|
||||
|
||||
export type ModuleNavigationIcon = (typeof MODULE_NAVIGATION_ICONS)[number];
|
||||
|
||||
const navigationIcons: ReadonlySet<string> = new Set(MODULE_NAVIGATION_ICONS);
|
||||
|
||||
export function isModuleNavigationIcon(value: unknown): value is ModuleNavigationIcon {
|
||||
return typeof value === "string" && navigationIcons.has(value);
|
||||
}
|
||||
|
||||
/** The most primary pages one module may contribute. A sidebar entry with more than this is a
|
||||
* module that needs its own information architecture, not a longer list. */
|
||||
export const MODULE_NAVIGATION_PAGES_MAX = 12;
|
||||
export const MODULE_NAVIGATION_PAGE_LABEL_MAX_LENGTH = 60;
|
||||
export const MODULE_NAVIGATION_PAGE_PATH_MAX_SEGMENTS = 4;
|
||||
|
||||
const PAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
const PATH_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
|
||||
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u;
|
||||
|
||||
/**
|
||||
* A page path, relative to the module's own root: one to four lowercase kebab-case segments joined
|
||||
* by `/` — `timelines`, `media/library`. There is no leading slash, no empty segment, no `.` or
|
||||
* `..`, no percent-encoding, no query, and no scheme, so a declared path can never escape
|
||||
* `/<module-id>/` or name anything but a page of this module. The module root itself is not a page
|
||||
* path: it is the module's landing view (ADR 0063).
|
||||
*/
|
||||
export function isModuleNavigationPagePath(value: unknown): value is string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 120) {
|
||||
return false;
|
||||
}
|
||||
const segments = value.split("/");
|
||||
return (
|
||||
segments.length <= MODULE_NAVIGATION_PAGE_PATH_MAX_SEGMENTS &&
|
||||
segments.every((segment) => PATH_SEGMENT_PATTERN.test(segment))
|
||||
);
|
||||
}
|
||||
|
||||
export const moduleNavigationPageSchema = z
|
||||
.object({
|
||||
/** Stable, never displayed: what the host and the module call this page. Renaming a label never
|
||||
* changes it, and the module's navigation API reports it as the current page. */
|
||||
id: z.string().max(64).regex(PAGE_ID_PATTERN),
|
||||
/** What a person reads in the sidebar. Plain text, trimmed and bounded. */
|
||||
label: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(MODULE_NAVIGATION_PAGE_LABEL_MAX_LENGTH)
|
||||
.refine((label) => !CONTROL_CHARACTER_PATTERN.test(label), {
|
||||
message: "A page label must not contain control characters.",
|
||||
}),
|
||||
path: z.string().refine(isModuleNavigationPagePath, {
|
||||
message:
|
||||
"A page path must be one to four lowercase kebab-case segments relative to the module root.",
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const moduleNavigationDeclarationSchema = z
|
||||
.object({
|
||||
/** The Lucide icon this module is drawn with. Omitted, the host draws its generic module icon. */
|
||||
icon: z
|
||||
.custom<ModuleNavigationIcon>((value) => isModuleNavigationIcon(value), {
|
||||
message: "The navigation icon must be one of the Lucide names in MODULE_NAVIGATION_ICONS.",
|
||||
})
|
||||
.optional(),
|
||||
/** Primary pages, in the order the sidebar shows them. */
|
||||
pages: z
|
||||
.array(moduleNavigationPageSchema)
|
||||
.max(MODULE_NAVIGATION_PAGES_MAX)
|
||||
.default(() => []),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((navigation, context) => {
|
||||
const ids = new Set<string>();
|
||||
const paths = new Set<string>();
|
||||
navigation.pages.forEach((page, index) => {
|
||||
if (ids.has(page.id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${page.id} is declared more than once.`,
|
||||
path: ["pages", index, "id"],
|
||||
});
|
||||
}
|
||||
ids.add(page.id);
|
||||
if (paths.has(page.path)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Two pages must not declare the same path.",
|
||||
path: ["pages", index, "path"],
|
||||
});
|
||||
}
|
||||
paths.add(page.path);
|
||||
});
|
||||
// A page beneath another would make "which page is open" ambiguous for every path under both,
|
||||
// and the sidebar's list is flat. Deeper routes belong to a page, not to a second page.
|
||||
navigation.pages.forEach((page, index) => {
|
||||
if (
|
||||
navigation.pages.some((other) => other !== page && page.path.startsWith(`${other.path}/`))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A page path must not lie beneath another declared page's path.",
|
||||
path: ["pages", index, "path"],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export type ModuleNavigationPage = Readonly<z.output<typeof moduleNavigationPageSchema>>;
|
||||
|
||||
/** A module's navigation as every consumer reads it, parsed or deeply frozen alike. */
|
||||
export interface ModuleNavigationDeclaration {
|
||||
readonly icon?: ModuleNavigationIcon | undefined;
|
||||
readonly pages: readonly ModuleNavigationPage[];
|
||||
}
|
||||
|
||||
/** What a module declared, as every consumer reads it: no icon and no pages for a module that
|
||||
* declares no navigation — every manifest-v8 module, and a v9 module that omits the field. */
|
||||
export const EMPTY_MODULE_NAVIGATION: ModuleNavigationDeclaration = Object.freeze({
|
||||
pages: Object.freeze([]),
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"manifestVersion": 8,
|
||||
"id": "signage",
|
||||
"name": "Signage",
|
||||
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
||||
"version": "0.1.5",
|
||||
"compatibility": {
|
||||
"platform": "^0.2.0",
|
||||
"moduleSdk": "^0.2.0"
|
||||
},
|
||||
"database": {
|
||||
"abi": 1
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "signage.content-providers",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "signage.slide-renderer",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "signage.playback",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "signage.displays",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"id": "signage.view",
|
||||
"baseline": "restricted",
|
||||
"description": "View Signage timelines, images, channels, provider health, and display health."
|
||||
},
|
||||
{
|
||||
"id": "signage.assets.manage",
|
||||
"baseline": "restricted",
|
||||
"description": "Upload, rename, expire, and delete Signage's own native images."
|
||||
},
|
||||
{
|
||||
"id": "signage.timelines.manage",
|
||||
"baseline": "restricted",
|
||||
"description": "Create, edit, reorder, duplicate, schedule, and delete saved timelines."
|
||||
},
|
||||
{
|
||||
"id": "signage.playback.control",
|
||||
"baseline": "restricted",
|
||||
"description": "Activate timelines and operate live playback transport for a channel group."
|
||||
},
|
||||
{
|
||||
"id": "signage.displays.manage",
|
||||
"baseline": "restricted",
|
||||
"description": "Create channel groups and audience channels, and assign or rename viewers."
|
||||
},
|
||||
{
|
||||
"id": "signage.display.view",
|
||||
"baseline": "restricted",
|
||||
"description": "Render the Signage full-viewport display and report its own health."
|
||||
}
|
||||
],
|
||||
"configuration": [
|
||||
{
|
||||
"key": "timezone",
|
||||
"type": "string",
|
||||
"default": "America/Chicago",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": "IANA time zone this installation's Signage schedules are written and displayed in."
|
||||
},
|
||||
{
|
||||
"key": "nativeImageDurationSeconds",
|
||||
"type": "integer",
|
||||
"default": 7,
|
||||
"minimum": 1,
|
||||
"maximum": 3600,
|
||||
"description": "How long a native Signage image is shown when no slot override applies."
|
||||
},
|
||||
{
|
||||
"key": "crossfadeMilliseconds",
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"minimum": 0,
|
||||
"maximum": 10000,
|
||||
"description": "How long a display fades an incoming slide over the outgoing one."
|
||||
},
|
||||
{
|
||||
"key": "defaultBackgroundMode",
|
||||
"type": "enum",
|
||||
"values": ["blurred", "solid", "black"],
|
||||
"default": "blurred",
|
||||
"description": "How a native image that is not 16:9 is backed when a slot says nothing."
|
||||
},
|
||||
{
|
||||
"key": "defaultBackgroundColor",
|
||||
"type": "string",
|
||||
"default": "#000000",
|
||||
"minLength": 7,
|
||||
"maxLength": 7,
|
||||
"description": "The solid background colour used when the background mode is solid."
|
||||
},
|
||||
{
|
||||
"key": "defaultBackgroundDarkened",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether a native image's background is darkened behind the foreground by default."
|
||||
},
|
||||
{
|
||||
"key": "defaultBlurPixels",
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"minimum": 0,
|
||||
"maximum": 120,
|
||||
"description": "Blur radius applied to a blurred native-image background."
|
||||
},
|
||||
{
|
||||
"key": "providerTimeoutMilliseconds",
|
||||
"type": "integer",
|
||||
"default": 2000,
|
||||
"minimum": 100,
|
||||
"maximum": 15000,
|
||||
"description": "How long Signage waits for one content-provider call before treating it as failed."
|
||||
},
|
||||
{
|
||||
"key": "playbackHistoryLimit",
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"minimum": 2,
|
||||
"maximum": 500,
|
||||
"description": "How many resolved selections per channel group are retained as playback history."
|
||||
},
|
||||
{
|
||||
"key": "viewerHeartbeatWriteSeconds",
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 5,
|
||||
"maximum": 600,
|
||||
"description": "Shortest interval between two persisted heartbeat writes for one steady viewer."
|
||||
}
|
||||
],
|
||||
"storage": {
|
||||
"buckets": [
|
||||
{
|
||||
"id": "native-images",
|
||||
"description": "Archived original uploads and generated renditions for Signage's native images."
|
||||
}
|
||||
]
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
"migrations": "./drizzle",
|
||||
"ui": "./dist/ui/index.js",
|
||||
"display": {
|
||||
"artifact": "./dist/display/index.js",
|
||||
"permission": "signage.display.view"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MODULE_DEPENDENCY_KINDS,
|
||||
MODULE_MANIFEST_VERSION,
|
||||
MODULE_RUNTIME_STATES,
|
||||
SUPPORTED_MODULE_MANIFEST_VERSIONS,
|
||||
MODULE_STORAGE_BLOCKED_REASON,
|
||||
packageRelativePathSchema,
|
||||
parseModuleManifest,
|
||||
@@ -256,15 +257,17 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(8);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 9, "8", 8.5, null]) {
|
||||
it("accepts exactly versions 8 and 9, each as itself, so no document is read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(9);
|
||||
expect(SUPPORTED_MODULE_MANIFEST_VERSIONS).toEqual([8, 9]);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 10, "8", "9", 8.5, null]) {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
safeParseModuleManifest({ ...validManifest(), manifestVersion: MODULE_MANIFEST_VERSION })
|
||||
.success,
|
||||
).toBe(true);
|
||||
for (const manifestVersion of SUPPORTED_MODULE_MANIFEST_VERSIONS) {
|
||||
const parsed = safeParseModuleManifest({ ...validManifest(), manifestVersion });
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.success && parsed.data.manifestVersion).toBe(manifestVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults contributions to an empty list for a module that contributes nothing", () => {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EMPTY_MODULE_NAVIGATION,
|
||||
isModuleNavigationIcon,
|
||||
isModuleNavigationPagePath,
|
||||
MODULE_NAVIGATION_ICONS,
|
||||
MODULE_NAVIGATION_PAGES_MAX,
|
||||
moduleManifestNavigation,
|
||||
parseModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
} from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Manifest v9's Dashboard navigation (ADR 0063), and the guarantee that released manifest v8
|
||||
* modules keep working unchanged.
|
||||
*/
|
||||
|
||||
const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
|
||||
function v9Manifest(navigation?: unknown): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 9,
|
||||
id: "signage",
|
||||
name: "Signage",
|
||||
version: "0.2.0",
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
...(navigation === undefined ? {} : { navigation }),
|
||||
};
|
||||
}
|
||||
|
||||
const pages = [
|
||||
{ id: "playback", label: "Playback", path: "playback" },
|
||||
{ id: "timelines", label: "Timelines", path: "timelines" },
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
];
|
||||
|
||||
function issues(input: unknown): string[] {
|
||||
const parsed = safeParseModuleManifest(input);
|
||||
return parsed.success
|
||||
? []
|
||||
: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
||||
}
|
||||
|
||||
describe("manifest v9 navigation", () => {
|
||||
it("accepts a single-page v9 module with no navigation at all: the generic icon, one root page", () => {
|
||||
const manifest = parseModuleManifest(v9Manifest());
|
||||
expect(manifest.manifestVersion).toBe(9);
|
||||
expect(moduleManifestNavigation(manifest)).toEqual({ pages: [] });
|
||||
expect(moduleManifestNavigation(manifest).icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a multi-page module with a Lucide icon, keeping pages in declared order", () => {
|
||||
const manifest = parseModuleManifest(v9Manifest({ icon: "monitor-play", pages }));
|
||||
const navigation = moduleManifestNavigation(manifest);
|
||||
expect(navigation.icon).toBe("monitor-play");
|
||||
expect(navigation.pages.map((page) => page.id)).toEqual(["playback", "timelines", "media"]);
|
||||
// Reversed in the manifest, reversed in the result: order is the author's, never re-sorted.
|
||||
const reversed = moduleManifestNavigation(
|
||||
parseModuleManifest(v9Manifest({ pages: [...pages].reverse() })),
|
||||
);
|
||||
expect(reversed.pages.map((page) => page.id)).toEqual(["media", "timelines", "playback"]);
|
||||
});
|
||||
|
||||
it("accepts an icon on its own, and pages on their own", () => {
|
||||
expect(moduleManifestNavigation(parseModuleManifest(v9Manifest({ icon: "calendar" })))).toEqual(
|
||||
{
|
||||
icon: "calendar",
|
||||
pages: [],
|
||||
},
|
||||
);
|
||||
expect(
|
||||
moduleManifestNavigation(parseModuleManifest(v9Manifest({ pages }))).icon,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts multi-segment page paths", () => {
|
||||
expect(
|
||||
issues(v9Manifest({ pages: [{ id: "library", label: "Library", path: "media/library" }] })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown icon", { icon: "not-an-icon" }],
|
||||
["a PascalCase component name", { icon: "MonitorPlay" }],
|
||||
["an icon as markup", { icon: "<svg onload=alert(1)>" }],
|
||||
["an icon as a URL", { icon: "https://cdn.example.test/icon.svg" }],
|
||||
["an icon as an object", { icon: { svg: "<path/>" } }],
|
||||
])("refuses %s", (_label, navigation) => {
|
||||
expect(issues(v9Manifest(navigation))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty path", ""],
|
||||
["the root as a page", "/"],
|
||||
["a leading slash", "/timelines"],
|
||||
["a trailing slash", "timelines/"],
|
||||
["an empty segment", "media//library"],
|
||||
["traversal", "../calendar"],
|
||||
["an inner traversal", "media/../../calendar"],
|
||||
["a dot segment", "./timelines"],
|
||||
["an absolute URL", "https://evil.example.test/x"],
|
||||
["a protocol-relative URL", "//evil.example.test"],
|
||||
["a scheme", "javascript:alert(1)"],
|
||||
["a query", "timelines?x=1"],
|
||||
["a fragment", "timelines#top"],
|
||||
["percent-encoding", "media%2Flibrary"],
|
||||
["a backslash", "media\\library"],
|
||||
["uppercase", "Timelines"],
|
||||
["whitespace", "time lines"],
|
||||
["too many segments", "a/b/c/d/e"],
|
||||
])("refuses a page path with %s", (_label, pagePath) => {
|
||||
expect(isModuleNavigationPagePath(pagePath)).toBe(false);
|
||||
expect(
|
||||
issues(v9Manifest({ pages: [{ id: "page", label: "Page", path: pagePath }] })),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an uppercase id", { id: "Playback", label: "Playback", path: "playback" }],
|
||||
["an id with a slash", { id: "media/library", label: "Library", path: "library" }],
|
||||
["an empty label", { id: "playback", label: " ", path: "playback" }],
|
||||
["an overlong label", { id: "playback", label: "x".repeat(61), path: "playback" }],
|
||||
[
|
||||
"a control character in a label",
|
||||
{ id: "playback", label: "Play\u0007back", path: "playback" },
|
||||
],
|
||||
["a line separator in a label", { id: "playback", label: "Play\u2028back", path: "playback" }],
|
||||
["an unknown page key", { id: "playback", label: "Playback", path: "playback", icon: "tv" }],
|
||||
["an executable field", { id: "playback", label: "Playback", path: "playback", onClick: "x" }],
|
||||
])("refuses a page with %s", (_label, page) => {
|
||||
expect(issues(v9Manifest({ pages: [page] }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses duplicate page ids and duplicate page paths, naming the duplicate", () => {
|
||||
expect(
|
||||
issues(
|
||||
v9Manifest({
|
||||
pages: [
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
{ id: "media", label: "More media", path: "more-media" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual(["navigation.pages.1.id: media is declared more than once."]);
|
||||
expect(
|
||||
issues(
|
||||
v9Manifest({
|
||||
pages: [
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
{ id: "library", label: "Library", path: "media" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual(["navigation.pages.1.path: Two pages must not declare the same path."]);
|
||||
});
|
||||
|
||||
it("refuses a page beneath another page, which would make the open page ambiguous", () => {
|
||||
expect(
|
||||
issues(
|
||||
v9Manifest({
|
||||
pages: [
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
{ id: "library", label: "Library", path: "media/library" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
"navigation.pages.1.path: A page path must not lie beneath another declared page's path.",
|
||||
]);
|
||||
// A shared prefix that is not a parent segment is unrelated.
|
||||
expect(
|
||||
issues(
|
||||
v9Manifest({
|
||||
pages: [
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
{ id: "media-library", label: "Library", path: "media-library" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("bounds the number of pages", () => {
|
||||
const many = Array.from({ length: MODULE_NAVIGATION_PAGES_MAX + 1 }, (_, index) => ({
|
||||
id: `page-${index}`,
|
||||
label: `Page ${index}`,
|
||||
path: `page-${index}`,
|
||||
}));
|
||||
expect(issues(v9Manifest({ pages: many }))).not.toEqual([]);
|
||||
expect(issues(v9Manifest({ pages: many.slice(0, MODULE_NAVIGATION_PAGES_MAX) }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses navigation for a module with no primary UI, and unknown navigation keys", () => {
|
||||
const noUi = {
|
||||
...v9Manifest({ icon: "calendar" }),
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
};
|
||||
expect(issues(noUi)).toEqual([
|
||||
"navigation: Navigation can only be declared by a module with a primary UI (entrypoints.ui).",
|
||||
]);
|
||||
expect(issues(v9Manifest({ groups: ["media"] }))).not.toEqual([]);
|
||||
expect(issues(v9Manifest({ widgets: [] }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("offers a curated, sorted, duplicate-free set of Lucide names, all in Lucide's kebab-case form", () => {
|
||||
expect([...MODULE_NAVIGATION_ICONS]).toEqual([...MODULE_NAVIGATION_ICONS].sort());
|
||||
expect(new Set(MODULE_NAVIGATION_ICONS).size).toBe(MODULE_NAVIGATION_ICONS.length);
|
||||
for (const icon of MODULE_NAVIGATION_ICONS) {
|
||||
expect(icon).toMatch(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u);
|
||||
expect(isModuleNavigationIcon(icon)).toBe(true);
|
||||
}
|
||||
expect(isModuleNavigationIcon("signage")).toBe(false);
|
||||
expect(Object.isFrozen(MODULE_NAVIGATION_ICONS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("released manifest v8 modules", () => {
|
||||
it("still accepts the released Signage 0.1.5 manifest exactly as published", async () => {
|
||||
const published = JSON.parse(
|
||||
await readFile(path.join(fixtures, "released-v8-signage-0.1.5.module.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const manifest = parseModuleManifest(published);
|
||||
|
||||
expect(manifest.manifestVersion).toBe(8);
|
||||
expect(manifest.id).toBe("signage");
|
||||
// The settings Signage declares are what Settings reads (ADR 0061), unchanged by v9.
|
||||
expect(manifest.configuration.length).toBeGreaterThan(0);
|
||||
// A v8 module declares no navigation: the generic icon and one root page.
|
||||
expect(moduleManifestNavigation(manifest)).toBe(EMPTY_MODULE_NAVIGATION);
|
||||
expect("navigation" in manifest).toBe(false);
|
||||
});
|
||||
|
||||
it("never reads navigation into a version 8 document: one that names it is refused, not upgraded", () => {
|
||||
const v8WithNavigation = { ...v9Manifest({ icon: "tv" }), manifestVersion: 8 };
|
||||
expect(safeParseModuleManifest(v8WithNavigation).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,11 @@ export {
|
||||
type ModuleConfigurationValue,
|
||||
type ModuleHttpMethod,
|
||||
type ModuleManifest,
|
||||
moduleManifestNavigation,
|
||||
MODULE_NAVIGATION_ICONS,
|
||||
type ModuleNavigationDeclaration,
|
||||
type ModuleNavigationIcon,
|
||||
type ModuleNavigationPage,
|
||||
type ModuleRealtimeChannel,
|
||||
type ModuleRealtimeChannelDefinitionInput,
|
||||
type ModuleRealtimeChannelRegistrar,
|
||||
|
||||
@@ -54,6 +54,7 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId,
|
||||
navigation: undefined,
|
||||
api: {
|
||||
async request(path) {
|
||||
requested.push(path);
|
||||
|
||||
@@ -35,6 +35,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "example",
|
||||
navigation: undefined,
|
||||
api: {
|
||||
async request(pathname) {
|
||||
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
|
||||
@@ -53,6 +54,7 @@ function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: { url: "https://example.test/display" },
|
||||
moduleId: "example",
|
||||
navigation: undefined,
|
||||
api: {
|
||||
async request(pathname) {
|
||||
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModuleUiContributions } from "./contribution.js";
|
||||
import type { ModuleUiNavigation } from "./navigation.js";
|
||||
import type { ModuleUiRealtimeClient } from "./realtime.js";
|
||||
|
||||
/**
|
||||
@@ -55,9 +56,10 @@ export type ModuleUiPrincipal =
|
||||
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
|
||||
*
|
||||
* This is deliberately the smallest set that proves the architecture rather than everything a real
|
||||
* screen will eventually want. Navigation, module configuration, organization/platform context, and
|
||||
* diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
|
||||
* field frozen into a public contract is harder to remove than to add. See ADR 0022.
|
||||
* screen will eventually want. Module configuration, organization/platform context, and diagnostics
|
||||
* reporting are **not** here: each needs a design that does not exist, and a speculative field
|
||||
* frozen into a public contract is harder to remove than to add. See ADR 0022. Navigation arrived
|
||||
* once its design did (ADR 0063).
|
||||
*
|
||||
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
|
||||
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
|
||||
@@ -102,6 +104,15 @@ export interface ModuleUiHostContext {
|
||||
* resolves `api.request`.
|
||||
*/
|
||||
readonly contributions: ModuleUiContributions;
|
||||
/**
|
||||
* Where this module is and how it moves between its own pages, and to its Settings page
|
||||
* (ADR 0063). See {@link ModuleUiNavigation}.
|
||||
*
|
||||
* `undefined` where the host has no addressable pages for it — the full-viewport display host,
|
||||
* which shows one screen, and any standalone host without routes. A module that offers
|
||||
* navigation checks for it, exactly as it checks `display`.
|
||||
*/
|
||||
readonly navigation: ModuleUiNavigation | undefined;
|
||||
}
|
||||
|
||||
export interface ModuleUiDisplayLocation {
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from "./define-module-ui.js";
|
||||
export * from "./host-context.js";
|
||||
export * from "./json.js";
|
||||
export * from "./module-ui.js";
|
||||
export * from "./navigation.js";
|
||||
export * from "./realtime.js";
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Where a mounted module UI is in the Dashboard, and how it moves (ADR 0063).
|
||||
*
|
||||
* A **host capability**, not a router. The host owns the address bar, the sidebar, and every
|
||||
* platform route; a module owns the pages beneath its own root. So a module never touches
|
||||
* `window.location` or `window.history`, never spells a platform route, and never builds another
|
||||
* module's address: it names paths **relative to its own root**, and the host maps them to real,
|
||||
* refreshable, bookmarkable Dashboard addresses — or refuses them.
|
||||
*
|
||||
* Paths:
|
||||
*
|
||||
* - `""` is the module's root, its landing view.
|
||||
* - `"timelines"` is a declared page; `"media/sermons/123"` is a route **beneath** the declared page
|
||||
* `media`, which a module may use for detail views without listing every record in its manifest.
|
||||
* - A leading `/` means the same thing as none: `"/timelines"` is still this module's `timelines`.
|
||||
* There is no way to name anything outside this module.
|
||||
* - Segments are unreserved URL characters only (letters, digits, `-`, `.`, `_`, `~`), never `.` or
|
||||
* `..`, never empty; no query, fragment, scheme, or percent-encoding.
|
||||
*
|
||||
* A path the host would not open — malformed, or outside the module's root and its declared pages —
|
||||
* is refused with a {@link ModuleUiNavigationError} rather than silently opening something else.
|
||||
*/
|
||||
export interface ModuleUiNavigation {
|
||||
/** Where this module is now. */
|
||||
location(): ModuleUiLocation;
|
||||
/** Notifies after the location changes — a link, Back or Forward, or a refresh of the sidebar.
|
||||
* Returns an unsubscribe. The module stays mounted while it moves between its own pages. */
|
||||
subscribe(listener: () => void): () => void;
|
||||
/**
|
||||
* Opens one of this module's own routes. `replace` corrects the current history entry instead of
|
||||
* adding one — for a route the module just respelled, not for ordinary navigation.
|
||||
*
|
||||
* @throws {ModuleUiNavigationError} for a path this module may not open.
|
||||
*/
|
||||
navigate(path: string, options?: ModuleUiNavigateOptions): void;
|
||||
/**
|
||||
* A link to one of this module's own routes, for a real `<a>`: `href` is the host's address for it,
|
||||
* so the link can be opened in a new tab, copied, or bookmarked, and `onClick` opens it in place on
|
||||
* an ordinary click while leaving a modified or middle click to the browser.
|
||||
*
|
||||
* @throws {ModuleUiNavigationError} for a path this module may not open.
|
||||
*/
|
||||
link(path: string): ModuleUiLink;
|
||||
/**
|
||||
* This module's page in the Dashboard's centralized Settings, or `undefined` when there is none for
|
||||
* this viewer — the module declares no configuration, or the viewer may not change it. A
|
||||
* presentation hint like {@link ModuleUiPermissions}: Settings enforces its own authority.
|
||||
*
|
||||
* Opening it carries the module's current location as the place to come back to, so Settings'
|
||||
* Back returns exactly here. The host validates that destination; the module neither builds nor
|
||||
* checks it.
|
||||
*/
|
||||
readonly settings: ModuleUiSettingsLocation | undefined;
|
||||
}
|
||||
|
||||
export interface ModuleUiLocation {
|
||||
/** This module's route, relative to its root: `""`, `"timelines"`, `"media/sermons/123"`. Never a
|
||||
* leading or trailing `/`. */
|
||||
readonly path: string;
|
||||
/** The declared page that route is, or lies beneath; `undefined` at the module's root. */
|
||||
readonly page: string | undefined;
|
||||
}
|
||||
|
||||
export interface ModuleUiNavigateOptions {
|
||||
readonly replace?: boolean;
|
||||
}
|
||||
|
||||
/** What a link's click handler reads: satisfied by a DOM `MouseEvent` and by React's synthetic one. */
|
||||
export interface ModuleUiLinkClickEvent {
|
||||
readonly button: number;
|
||||
readonly metaKey: boolean;
|
||||
readonly ctrlKey: boolean;
|
||||
readonly shiftKey: boolean;
|
||||
readonly altKey: boolean;
|
||||
readonly defaultPrevented: boolean;
|
||||
preventDefault(): void;
|
||||
}
|
||||
|
||||
export interface ModuleUiLink {
|
||||
readonly href: string;
|
||||
onClick(event: ModuleUiLinkClickEvent): void;
|
||||
}
|
||||
|
||||
export interface ModuleUiSettingsLocation {
|
||||
/** Opens this module's Settings page, remembering where the module was. */
|
||||
open(): void;
|
||||
/** The same, as a link: its `href` includes the place to return to, so it also works in a new tab. */
|
||||
link(): ModuleUiLink;
|
||||
}
|
||||
|
||||
export type ModuleUiNavigationErrorCode =
|
||||
/** Not a path this contract can express: absolute, traversing, malformed, or out of bounds. */
|
||||
| "MODULE_NAVIGATION_PATH_INVALID"
|
||||
/** A well-formed path that is neither the module's root nor a declared page or beneath one. */
|
||||
| "MODULE_NAVIGATION_PATH_UNKNOWN";
|
||||
|
||||
const navigationErrorMessages: Readonly<Record<ModuleUiNavigationErrorCode, string>> =
|
||||
Object.freeze({
|
||||
MODULE_NAVIGATION_PATH_INVALID: "A module asked to open a path outside its own routes.",
|
||||
MODULE_NAVIGATION_PATH_UNKNOWN: "A module asked to open a page it does not declare.",
|
||||
});
|
||||
|
||||
/** Carries a stable code and a fixed message — never the path the module asked for. */
|
||||
export class ModuleUiNavigationError extends Error {
|
||||
readonly code: ModuleUiNavigationErrorCode;
|
||||
|
||||
constructor(code: ModuleUiNavigationErrorCode) {
|
||||
super(navigationErrorMessages[code]);
|
||||
this.name = "ModuleUiNavigationError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId,
|
||||
navigation: undefined,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => canPublish },
|
||||
@@ -214,6 +215,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: { url: "https://announcements.example.org/display" },
|
||||
moduleId,
|
||||
navigation: undefined,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => canPublish },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiNavigation,
|
||||
ModuleUiNavigationError,
|
||||
} from "../src/index.js";
|
||||
|
||||
/** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the
|
||||
* Dashboard's implementation is proven in `surfaces/shell/tests/module-navigation.test.tsx`. */
|
||||
describe("module UI navigation contract", () => {
|
||||
it("reports a refused path with a stable code and a fixed message that never echoes the path", () => {
|
||||
for (const code of [
|
||||
"MODULE_NAVIGATION_PATH_INVALID",
|
||||
"MODULE_NAVIGATION_PATH_UNKNOWN",
|
||||
] as const) {
|
||||
const error = new ModuleUiNavigationError(code);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe("ModuleUiNavigationError");
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.stack).toBe(`ModuleUiNavigationError: ${error.message}`);
|
||||
expect(error.message).not.toMatch(/[/:?#]/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("makes navigation a context member a host without pages leaves undefined", () => {
|
||||
// A type-level contract: a context may say it has no navigation, as the display host does, and
|
||||
// a module reads it through an optional check.
|
||||
const describe = (context: Pick<ModuleUiHostContext, "navigation">): string =>
|
||||
context.navigation?.location().path ?? "no navigation";
|
||||
const navigation: ModuleUiNavigation = {
|
||||
link: () => ({ href: "/m/x", onClick: () => undefined }),
|
||||
location: () => ({ page: "x", path: "x" }),
|
||||
navigate: () => undefined,
|
||||
settings: undefined,
|
||||
subscribe: () => () => undefined,
|
||||
};
|
||||
expect(describe({ navigation: undefined })).toBe("no navigation");
|
||||
expect(describe({ navigation })).toBe("x");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user