Make the module navigation capability an optional context member
CI / verify (pull_request) Successful in 1m11s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m35s
CI / verify (pull_request) Successful in 1m11s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m35s
ModuleUiHostContext.navigation is now `navigation?: ModuleUiNavigation | undefined`, so the public context change is source-compatible: a context built before navigation existed (without the member) still compiles unchanged, as 0.x patch releases require. The Dashboard supplies navigation; hosts without addressable pages, including the display host, omit the member; modules feature-detect it. Existing hosts' tests are restored to their pre-PR shape, and a module-ui-sdk test proves a pre-navigation context still satisfies ModuleUiHostContext (and fails if the member is ever made required). Runtime behaviour, manifest v8/v9 compatibility, and security rules are unchanged. ADR 0063 and the module development guide say so. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ae360ef318
commit
c78f1318b4
@@ -479,7 +479,6 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
"contributions",
|
"contributions",
|
||||||
"display",
|
"display",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"navigation",
|
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
"realtime",
|
"realtime",
|
||||||
|
|||||||
@@ -953,7 +953,6 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
display: undefined,
|
display: undefined,
|
||||||
moduleId: "announcements",
|
moduleId: "announcements",
|
||||||
navigation: undefined,
|
|
||||||
permissions: { has: () => false },
|
permissions: { has: () => false },
|
||||||
principal: { kind: "anonymous" as const },
|
principal: { kind: "anonymous" as const },
|
||||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||||
|
|||||||
@@ -176,8 +176,14 @@ The shell parses both strictly.
|
|||||||
### The module's navigation capability
|
### The module's navigation capability
|
||||||
|
|
||||||
`ModuleUiHostContext.navigation` (`@ekklesia/module-ui-sdk`) is a narrow host capability, not a
|
`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
|
router, and an **optional** member (`navigation?: ModuleUiNavigation`). A host with addressable
|
||||||
host without routes.
|
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
|
```ts
|
||||||
interface ModuleUiNavigation {
|
interface ModuleUiNavigation {
|
||||||
@@ -208,7 +214,7 @@ interface ModuleUiNavigation {
|
|||||||
rebuilt against the new SDK can already link to its Settings page.
|
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
|
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
|
### 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
|
- **Packages.** `@ekklesia/contracts` gains `module-navigation.ts`, the v8/v9 manifest union, and
|
||||||
`moduleManifestNavigation`. `@ekklesia/module-ui-sdk` gains `navigation.ts` and
|
`moduleManifestNavigation`. `@ekklesia/module-ui-sdk` gains `navigation.ts` and
|
||||||
`ModuleUiHostContext.navigation`. `@ekklesia/module-sdk` re-exports the navigation helpers 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
|
types. Every change is additive at the source level as well as at run time: existing module code,
|
||||||
`navigation: undefined`.
|
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
|
- **Core** publishes each module's navigation beside its name. The navigation response gains a
|
||||||
`navigation` member per module.
|
`navigation` member per module.
|
||||||
- **The shell** reads module routes at any depth, draws icons and pages, and supplies the
|
- **The shell** reads module routes at any depth, draws icons and pages, and supplies the
|
||||||
|
|||||||
@@ -720,7 +720,8 @@ artifact is the role selection.
|
|||||||
|
|
||||||
### What the host gives you
|
### 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 |
|
| 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) |
|
| `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) |
|
| `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 |
|
| `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.
|
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
|
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:
|
never build a Dashboard URL yourself:
|
||||||
|
|
||||||
```tsx
|
```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?.location(); // { path: "media/sermons/123", page: "media" }
|
||||||
navigation?.subscribe(() => rerender()); // every move: yours, the sidebar's, Back and Forward
|
navigation?.subscribe(() => rerender()); // every move: yours, the sidebar's, Back and Forward
|
||||||
navigation?.navigate("timelines"); // or { replace: true } to respell the current entry
|
navigation?.navigate("timelines"); // or { replace: true } to respell the current entry
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH
|
|||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
display: undefined,
|
display: undefined,
|
||||||
moduleId,
|
moduleId,
|
||||||
navigation: undefined,
|
|
||||||
api: {
|
api: {
|
||||||
async request(path) {
|
async request(path) {
|
||||||
requested.push(path);
|
requested.push(path);
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
|||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
display: undefined,
|
display: undefined,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
navigation: undefined,
|
|
||||||
api: {
|
api: {
|
||||||
async request(pathname) {
|
async request(pathname) {
|
||||||
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
|
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
|
||||||
@@ -54,7 +53,6 @@ function standaloneContext(requested: string[]): ModuleUiHostContext {
|
|||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
display: { url: "https://example.test/display" },
|
display: { url: "https://example.test/display" },
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
navigation: undefined,
|
|
||||||
api: {
|
api: {
|
||||||
async request(pathname) {
|
async request(pathname) {
|
||||||
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
|
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
|
||||||
|
|||||||
@@ -108,11 +108,13 @@ export interface ModuleUiHostContext {
|
|||||||
* Where this module is and how it moves between its own pages, and to its Settings page
|
* Where this module is and how it moves between its own pages, and to its Settings page
|
||||||
* (ADR 0063). See {@link ModuleUiNavigation}.
|
* (ADR 0063). See {@link ModuleUiNavigation}.
|
||||||
*
|
*
|
||||||
* `undefined` where the host has no addressable pages for it — the full-viewport display host,
|
* **Optional**, so adding it broke no existing host: a context built before navigation existed
|
||||||
* which shows one screen, and any standalone host without routes. A module that offers
|
* is still a complete context. A host with addressable module pages — Ekklesia's Dashboard —
|
||||||
* navigation checks for it, exactly as it checks `display`.
|
* 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 {
|
export interface ModuleUiDisplayLocation {
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
|||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
display: undefined,
|
display: undefined,
|
||||||
moduleId,
|
moduleId,
|
||||||
navigation: undefined,
|
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
permissions: { has: () => canPublish },
|
permissions: { has: () => canPublish },
|
||||||
@@ -215,7 +214,6 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
|||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
display: { url: "https://announcements.example.org/display" },
|
display: { url: "https://announcements.example.org/display" },
|
||||||
moduleId,
|
moduleId,
|
||||||
navigation: undefined,
|
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
permissions: { has: () => canPublish },
|
permissions: { has: () => canPublish },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type ModuleUiHostContext,
|
type ModuleUiHostContext,
|
||||||
type ModuleUiNavigation,
|
type ModuleUiNavigation,
|
||||||
ModuleUiNavigationError,
|
ModuleUiNavigationError,
|
||||||
|
NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
/** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the
|
/** 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", () => {
|
it("keeps a context built before navigation existed a complete, valid context", () => {
|
||||||
// A type-level contract: a context may say it has no navigation, as the display host does, and
|
// Exactly the seven members every host supplied before ADR 0063, with no `navigation` member at
|
||||||
// a module reads it through an optional check.
|
// all. This must keep compiling unchanged: `tsc` over this file is the compatibility proof, and
|
||||||
const describe = (context: Pick<ModuleUiHostContext, "navigation">): string =>
|
// `satisfies` makes the check exact rather than structural-by-accident.
|
||||||
context.navigation?.location().path ?? "no navigation";
|
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 = {
|
const navigation: ModuleUiNavigation = {
|
||||||
link: () => ({ href: "/m/x", onClick: () => undefined }),
|
link: () => ({ href: "/m/x", onClick: () => undefined }),
|
||||||
location: () => ({ page: "x", path: "x" }),
|
location: () => ({ page: "x", path: "x" }),
|
||||||
@@ -34,7 +50,14 @@ describe("module UI navigation contract", () => {
|
|||||||
settings: undefined,
|
settings: undefined,
|
||||||
subscribe: () => () => undefined,
|
subscribe: () => () => undefined,
|
||||||
};
|
};
|
||||||
expect(describe({ navigation: undefined })).toBe("no navigation");
|
const where = (context: Pick<ModuleUiHostContext, "navigation">): string =>
|
||||||
expect(describe({ navigation })).toBe("x");
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export interface CreateModuleUiContextOptions {
|
|||||||
* renderer without a real network. Production composition leaves it unset. */
|
* renderer without a real network. Production composition leaves it unset. */
|
||||||
readonly importModule?: (url: string) => Promise<unknown>;
|
readonly importModule?: (url: string) => Promise<unknown>;
|
||||||
/** The module's navigation capability (ADR 0063), from a host whose pages are addressable — the
|
/** 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;
|
readonly navigation?: ModuleUiNavigation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,8 +361,8 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
|
|||||||
moduleId: options.descriptor.id,
|
moduleId: options.descriptor.id,
|
||||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||||
// Only a host with addressable module pages supplies navigation (ADR 0063); the display host
|
// Only a host with addressable module pages supplies navigation (ADR 0063); the display host
|
||||||
// shows one screen and supplies none.
|
// shows one screen and omits the member entirely, exactly as before navigation existed.
|
||||||
navigation: options.navigation,
|
...(options.navigation === undefined ? {} : { navigation: options.navigation }),
|
||||||
principal: options.principal,
|
principal: options.principal,
|
||||||
realtime: createModuleRealtimeClient(options.config, options.descriptor),
|
realtime: createModuleRealtimeClient(options.config, options.descriptor),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -251,13 +251,12 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
"contributions",
|
"contributions",
|
||||||
"display",
|
"display",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"navigation",
|
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
"realtime",
|
"realtime",
|
||||||
]);
|
]);
|
||||||
// A display is one screen with no pages: the display host offers no navigation (ADR 0063).
|
// A display is one screen with no pages: the display host omits navigation (ADR 0063).
|
||||||
expect(seenContext?.navigation).toBeUndefined();
|
expect(seenContext && "navigation" in seenContext).toBe(false);
|
||||||
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||||
expect(isolatedText(query("display-module-container"))).toBe("permitted");
|
expect(isolatedText(query("display-module-container"))).toBe("permitted");
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ describe("module UI host context", () => {
|
|||||||
expect(context.display?.url).toBe("https://api.example.test/display/example");
|
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 { implementation } = recordingFetch();
|
||||||
|
|
||||||
const context = contextFor(implementation);
|
const context = contextFor(implementation);
|
||||||
@@ -579,14 +579,14 @@ describe("module UI host context", () => {
|
|||||||
"contributions",
|
"contributions",
|
||||||
"display",
|
"display",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"navigation",
|
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
"realtime",
|
"realtime",
|
||||||
]);
|
]);
|
||||||
expect(context.moduleId).toBe("example");
|
expect(context.moduleId).toBe("example");
|
||||||
// Navigation comes only from a host with addressable pages (ADR 0063); this one supplies none.
|
// Navigation comes only from a host with addressable pages (ADR 0063). Without it the member is
|
||||||
expect(context.navigation).toBeUndefined();
|
// 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" });
|
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ function recordingHost(options: {
|
|||||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
display: undefined,
|
display: undefined,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
navigation: undefined,
|
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
permissions: { has: () => options.canRead },
|
permissions: { has: () => options.canRead },
|
||||||
|
|||||||
Reference in New Issue
Block a user