diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 5e9a0ef..f73af62 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -479,7 +479,6 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () "contributions", "display", "moduleId", - "navigation", "permissions", "principal", "realtime", diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index 2cd768f..bee6f08 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -953,7 +953,6 @@ describe("module UI artifact publication — the generic loader consumes what is contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId: "announcements", - navigation: undefined, permissions: { has: () => false }, principal: { kind: "anonymous" as const }, realtime: { subscribe: () => ({ close: () => undefined }) }, diff --git a/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md b/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md index 3574ec2..55c1f0e 100644 --- a/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md +++ b/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md @@ -176,8 +176,14 @@ The shell parses both strictly. ### The module's navigation capability `ModuleUiHostContext.navigation` (`@ekklesia/module-ui-sdk`) is a narrow host capability, not a -router. It is `undefined` where a host has no addressable pages: the display host, and a standalone -host without routes. +router, and an **optional** member (`navigation?: ModuleUiNavigation`). A host with addressable +module pages (Ekklesia's Dashboard) supplies it. A host without them (the display host, and a +standalone host without routes) omits it, and may also set it to `undefined`. + +The member is optional so that adding it breaks no one. Ekklesia's 0.x versioning makes patch +releases compatible, and a context built before navigation existed must stay a complete, valid +context that compiles unchanged. A `@ekklesia/module-ui-sdk` test holds exactly that, and fails if +the member is ever made required. ```ts interface ModuleUiNavigation { @@ -208,7 +214,7 @@ interface ModuleUiNavigation { rebuilt against the new SDK can already link to its Settings page. Adding a context member changes nothing about how an artifact is mounted, so the mount ABI stays at -version 2. A module that uses `navigation` checks for it, exactly as it checks `display`. +version 2. A module that uses `navigation` feature-detects it (`context.navigation?.navigate(…)`). ### Return-to-origin: one carrier, the `return` query parameter @@ -262,8 +268,8 @@ parameter from it. - **Packages.** `@ekklesia/contracts` gains `module-navigation.ts`, the v8/v9 manifest union, and `moduleManifestNavigation`. `@ekklesia/module-ui-sdk` gains `navigation.ts` and `ModuleUiHostContext.navigation`. `@ekklesia/module-sdk` re-exports the navigation helpers and - types. All changes are additive for module code. A host that builds a context itself must now say - `navigation: undefined`. + types. Every change is additive at the source level as well as at run time: existing module code, + and an existing host that builds a context itself, compile and run unchanged. - **Core** publishes each module's navigation beside its name. The navigation response gains a `navigation` member per module. - **The shell** reads module routes at any depth, draws icons and pages, and supplies the diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 8c707c3..ba41552 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -720,7 +720,8 @@ artifact is the role selection. ### What the host gives you -`mount` receives exactly eight members, and nothing else is available: +`mount` receives these members, and nothing else is available. `navigation` is optional: a host that +has no addressable pages leaves it out, so feature-detect it. | Member | What it is | | --- | --- | @@ -731,7 +732,7 @@ artifact is the role selection. | `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) | | `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) | | `display` | Your display page's URL, or `undefined` when you publish none | -| `navigation` | Where your module is, and how it moves between its own pages and to its Settings page — see [Module navigation](#module-navigation). `undefined` on the display host | +| `navigation` | Where your module is, and how it moves between its own pages and to its Settings page — see [Module navigation](#module-navigation). Optional: absent on the display host | Module configuration, organization context, and diagnostics reporting are **not** in the contract. They need designs that do not exist, and a speculative field frozen into a public contract is harder @@ -795,7 +796,7 @@ A refresh, a bookmark, and Back and Forward all land on the same route. never build a Dashboard URL yourself: ```tsx -const { navigation } = context; // undefined on the display host +const { navigation } = context; // optional: absent on the display host navigation?.location(); // { path: "media/sermons/123", page: "media" } navigation?.subscribe(() => rerender()); // every move: yours, the sidebar's, Back and Forward navigation?.navigate("timelines"); // or { replace: true } to respell the current entry diff --git a/packages/module-ui-host/tests/load-module-ui.test.ts b/packages/module-ui-host/tests/load-module-ui.test.ts index f9a8375..ef9535f 100644 --- a/packages/module-ui-host/tests/load-module-ui.test.ts +++ b/packages/module-ui-host/tests/load-module-ui.test.ts @@ -54,7 +54,6 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId, - navigation: undefined, api: { async request(path) { requested.push(path); diff --git a/packages/module-ui-host/tests/real-artifact.test.ts b/packages/module-ui-host/tests/real-artifact.test.ts index 9adc8f7..0b1e391 100644 --- a/packages/module-ui-host/tests/real-artifact.test.ts +++ b/packages/module-ui-host/tests/real-artifact.test.ts @@ -35,7 +35,6 @@ 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, "")}`); @@ -54,7 +53,6 @@ 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, "")}`); diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts index 3762462..27f6c61 100644 --- a/packages/module-ui-sdk/src/host-context.ts +++ b/packages/module-ui-sdk/src/host-context.ts @@ -108,11 +108,13 @@ export interface ModuleUiHostContext { * 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`. + * **Optional**, so adding it broke no existing host: a context built before navigation existed + * is still a complete context. A host with addressable module pages — Ekklesia's Dashboard — + * supplies it; a host without them — the full-viewport display host, which shows one screen, or a + * standalone host without routes — omits it (or sets it to `undefined`). A module feature-detects + * it: `context.navigation?.navigate("timelines")`. */ - readonly navigation: ModuleUiNavigation | undefined; + readonly navigation?: ModuleUiNavigation | undefined; } export interface ModuleUiDisplayLocation { diff --git a/packages/module-ui-sdk/tests/module-ui-contract.test.ts b/packages/module-ui-sdk/tests/module-ui-contract.test.ts index 1451d4d..525a646 100644 --- a/packages/module-ui-sdk/tests/module-ui-contract.test.ts +++ b/packages/module-ui-sdk/tests/module-ui-contract.test.ts @@ -188,7 +188,6 @@ 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 }, @@ -215,7 +214,6 @@ 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 }, diff --git a/packages/module-ui-sdk/tests/navigation.test.ts b/packages/module-ui-sdk/tests/navigation.test.ts index 6ee4fce..6904837 100644 --- a/packages/module-ui-sdk/tests/navigation.test.ts +++ b/packages/module-ui-sdk/tests/navigation.test.ts @@ -3,6 +3,7 @@ import { type ModuleUiHostContext, type ModuleUiNavigation, ModuleUiNavigationError, + NO_MODULE_UI_CONTRIBUTIONS, } from "../src/index.js"; /** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the @@ -22,11 +23,26 @@ describe("module UI navigation contract", () => { } }); - 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): string => - context.navigation?.location().path ?? "no navigation"; + it("keeps a context built before navigation existed a complete, valid context", () => { + // Exactly the seven members every host supplied before ADR 0063, with no `navigation` member at + // all. This must keep compiling unchanged: `tsc` over this file is the compatibility proof, and + // `satisfies` makes the check exact rather than structural-by-accident. + const preNavigationContext = { + api: { request: async () => new Response(null, { status: 204 }) }, + contributions: NO_MODULE_UI_CONTRIBUTIONS, + display: undefined, + moduleId: "announcements", + permissions: { has: () => false }, + principal: { kind: "anonymous" }, + realtime: { subscribe: () => ({ close: () => undefined }) }, + } satisfies ModuleUiHostContext; + const asContext: ModuleUiHostContext = preNavigationContext; + + expect("navigation" in asContext).toBe(false); + expect(asContext.navigation?.location().path).toBeUndefined(); + }); + + it("lets a host supply navigation, omit it, or set it to undefined, and a module feature-detect it", () => { const navigation: ModuleUiNavigation = { link: () => ({ href: "/m/x", onClick: () => undefined }), location: () => ({ page: "x", path: "x" }), @@ -34,7 +50,14 @@ describe("module UI navigation contract", () => { settings: undefined, subscribe: () => () => undefined, }; - expect(describe({ navigation: undefined })).toBe("no navigation"); - expect(describe({ navigation })).toBe("x"); + const where = (context: Pick): string => + context.navigation?.location().path ?? "no navigation"; + + expect(where({ navigation })).toBe("x"); + expect(where({})).toBe("no navigation"); + expect(where({ navigation: undefined })).toBe("no navigation"); + // Present, it must be the real capability — not any object. + // @ts-expect-error a navigation member must satisfy ModuleUiNavigation + expect(where({ navigation: { location: () => "x" } })).toBeDefined(); }); }); diff --git a/surfaces/shell/src/module-context.ts b/surfaces/shell/src/module-context.ts index a904867..0431ae1 100644 --- a/surfaces/shell/src/module-context.ts +++ b/surfaces/shell/src/module-context.ts @@ -274,7 +274,7 @@ export interface CreateModuleUiContextOptions { * renderer without a real network. Production composition leaves it unset. */ readonly importModule?: (url: string) => Promise; /** The module's navigation capability (ADR 0063), from a host whose pages are addressable — the - * Dashboard. Omitted, the context carries `navigation: undefined`. */ + * Dashboard. Omitted, the context omits the member, as it did before navigation existed. */ readonly navigation?: ModuleUiNavigation; } @@ -361,8 +361,8 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo moduleId: options.descriptor.id, permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), // Only a host with addressable module pages supplies navigation (ADR 0063); the display host - // shows one screen and supplies none. - navigation: options.navigation, + // shows one screen and omits the member entirely, exactly as before navigation existed. + ...(options.navigation === undefined ? {} : { navigation: options.navigation }), principal: options.principal, realtime: createModuleRealtimeClient(options.config, options.descriptor), }); diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index 24453cf..a289339 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -251,13 +251,12 @@ describe("display host — full-viewport boundary", () => { "contributions", "display", "moduleId", - "navigation", "permissions", "principal", "realtime", ]); - // A display is one screen with no pages: the display host offers no navigation (ADR 0063). - expect(seenContext?.navigation).toBeUndefined(); + // A display is one screen with no pages: the display host omits navigation (ADR 0063). + expect(seenContext && "navigation" in seenContext).toBe(false); expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(isolatedText(query("display-module-container"))).toBe("permitted"); expect( diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts index a8271a5..65414bc 100644 --- a/surfaces/shell/tests/host-protocol.test.ts +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -569,7 +569,7 @@ describe("module UI host context", () => { expect(context.display?.url).toBe("https://api.example.test/display/example"); }); - it("exposes exactly the eight contract members and the browser principal", () => { + it("exposes exactly the seven contract members and the browser principal", () => { const { implementation } = recordingFetch(); const context = contextFor(implementation); @@ -579,14 +579,14 @@ describe("module UI host context", () => { "contributions", "display", "moduleId", - "navigation", "permissions", "principal", "realtime", ]); expect(context.moduleId).toBe("example"); - // Navigation comes only from a host with addressable pages (ADR 0063); this one supplies none. - expect(context.navigation).toBeUndefined(); + // Navigation comes only from a host with addressable pages (ADR 0063). Without it the member is + // omitted, so the context is exactly the shape it was before navigation existed. + expect("navigation" in context).toBe(false); expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" }); }); diff --git a/templates/module/tests/module-ui.test.ts b/templates/module/tests/module-ui.test.ts index 1618e18..6dcc375 100644 --- a/templates/module/tests/module-ui.test.ts +++ b/templates/module/tests/module-ui.test.ts @@ -58,7 +58,6 @@ function recordingHost(options: { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId: "example", - navigation: undefined, api, principal: { kind: "anonymous" }, permissions: { has: () => options.canRead },